[UE5] 추출 슈터 4-1. GAS 도입: ASC를 어디에 둘 것인가

게시:     수정

카테고리:

태그: , ,

📌 EmploymentProj 4단계 GAS 이관 첫 번째 글입니다. 👾 깃허브 · 📚 시리즈 목차 · ← 3-3. 예측 이펙트, 그리고 죽어 있던 코드 한 줄


왜 옮겼나

3단계까지 히트스캔, 본 단위 판정, 지연 보상을 전부 직접 만들었다. 동작은 했다. 문제는 기능을 하나 붙일 때마다 AEPCharacterUEPCombatComponent가 같이 부푼다는 것이었다.

막힌 것 당시 코드 실제로 겪은 증상
상태가 흩어짐 WeaponState enum, HP UPROPERTY, LastServerFireTime이 각자 논다 같은 검증을 여러 곳에서 중복
복제 한계 “재장전 중”에 해당하는 개념이 없다 다른 클라가 그 상태를 알 수 없다
확장 비용 무기나 스킬을 추가하면 Character를 연다 사이드이펙트
수치 관리 탄약·HP·쿨타임이 각각 UPROPERTY 복제 전략이 제각각
// 3단계 EPCharacter.h. 직접 관리하던 HP
UPROPERTY(ReplicatedUsing = OnRep_HP) int32 HP = 100;
int32 MaxHP = 100;
FORCEINLINE bool IsDead() const { return HP <= 0; }
virtual float TakeDamage(...) override;

두 번째 줄이 특히 아팠다. 3단계 마지막 글에서 “예측은 넣었는데 롤백은 없다”고 적었는데, 그 롤백을 직접 만들려면 예측 키 시스템을 통째로 구현해야 한다. GAS에는 그게 이미 있다.

이 시리즈는 GAS 튜토리얼이 아니다. 직접 만든 것을 GAS로 옮긴 기록이다. 그래서 각 편마다 “무엇을 지웠는가”와 “무엇을 그대로 뒀는가”를 같이 적는다.

용어

이 편에서 한 번만 정리한다.

   
ASC (AbilitySystemComponent) GAS의 중심. Attribute·Tag·Ability·Effect를 전부 소유한다
GA (GameplayAbility) “발사한다”, “재장전한다” 같은 행동 하나
GE (GameplayEffect) Attribute를 바꾸거나 Tag를 붙이는 효과. 즉발/지속/무한
AttributeSet HP·탄약 같은 수치 묶음
GameplayTag 계층형 문자열 상태 표식. State.Reloading 같은 것

1. 모듈 추가

// EmploymentProj.Build.cs
PublicDependencyModuleNames.AddRange(new string[] {
    "GameplayAbilities", "GameplayTags", "GameplayTasks"
});

Private가 아니라 Public이어야 한다. IAbilitySystemInterface를 public 헤더에서 상속하기 때문이다. Private으로 넣으면 링크가 깨진다.

2. InitGlobalData, 빠뜨리면 조용히 죽는다

// EPGameInstance.cpp
void UEPGameInstance::Init()
{
    Super::Init();
    UAbilitySystemGlobals::Get().InitGlobalData();
}

프로젝트 전체에서 반드시 한 번 불러야 GE Execution과 GameplayCue가 등록된다. 안 하면 에러 없이 아무 일도 일어나지 않는다. GE를 적용해도 수치가 그대로다.

GAS 첫 삽에서 제일 많이 막히는 지점이라 먼저 적어둔다. 크래시가 나면 차라리 낫다. 조용히 안 되는 게 제일 나쁘다.


3. ASC를 PlayerState에 둔다

GAS를 도입하면 제일 먼저 정해야 하는 것이다.

배치 장점 단점
Character 접근이 짧다, 캐릭터별로 완결 죽으면 ASC가 같이 사라진다
PlayerState (채택) 사망·리스폰에도 Grant·GE·Attribute가 보존된다 접근이 한 단계 길어진다

추출 슈터는 사망과 리스폰이 반복되는 게임이다. Character에 두면 리스폰마다 어빌리티를 다시 부여하고 쿨타임이 초기화된다. PlayerState에는 이미 킬 수 같은 영속 데이터가 있으니 자연스러운 확장 위치이기도 하다.

// EPPlayerState.cpp
AEPPlayerState::AEPPlayerState()
{
    AbilitySystemComponent = CreateDefaultSubobject<UAbilitySystemComponent>(TEXT("AbilitySystemComponent"));
    AbilitySystemComponent->SetIsReplicated(true);
    AbilitySystemComponent->SetReplicationMode(EGameplayEffectReplicationMode::Mixed);

    // PlayerState 기본 빈도가 낮아 Attribute 복제가 눈에 띄게 늦다
    NetUpdateFrequency = 100.f;

    // ASC와 같은 Actor의 SubObject로 만들면 자동 등록된다
    AttributeSet = CreateDefaultSubobject<UEPAttributeSet>(TEXT("AttributeSet"));
}

Replication Mode 세 가지

모드 대상 용도
Full 모두에게 GE 전체 싱글플레이
Mixed (채택) 소유 클라는 GE 전체, 타 클라는 Tag/Cue만 멀티플레이어 플레이어 캐릭터
Minimal 아무에게도 GE 미복제, Tag/Cue만 AI, NPC

Mixed는 PlayerState의 Owner가 Controller여야 동작한다. UE 4.24 이후로는 PossessedBy가 처리해주지만, 안 되면 GE가 소유 클라에도 안 온다.

이 제약은 마지막 편(HUD)에서 다시 나온다. 다른 플레이어의 쿨타임을 UI에 못 그리는 이유가 이것이다.


4. InitAbilityActorInfo는 서버와 클라 양쪽에서

가장 흔한 실수 지점이다.

// 서버 경로
void AEPCharacter::PossessedBy(AController* NewController)
{
    Super::PossessedBy(NewController);
    if (AEPPlayerState* PS = GetPlayerState<AEPPlayerState>())
    {
        AbilitySystemComponent = PS->GetAbilitySystemComponent();
        InitASC();

        // Attribute 초기화는 서버만. 클라는 복제로 받는다
        if (UEPAttributeSet* AS = PS->GetAttributeSet())
        {
            AS->InitHealth(100.f);
            AS->InitMaxHealth(100.f);
        }

        // 리스폰 시 이전 사망 상태가 남는 것 방지
        AbilitySystemComponent->SetTagMapCount(TAG_State_Dead, 0);
    }
}

// 클라 경로. PlayerState 복제가 끝난 시점
void AEPCharacter::OnRep_PlayerState()
{
    Super::OnRep_PlayerState();
    if (AEPPlayerState* PS = GetPlayerState<AEPPlayerState>())
    {
        AbilitySystemComponent = PS->GetAbilitySystemComponent();
        InitASC();
    }
}

void AEPCharacter::InitASC()
{
    AEPPlayerState* PS = GetPlayerState<AEPPlayerState>();
    if (!PS || !AbilitySystemComponent) return;

    // Owner  = PlayerState. 어빌리티 상태를 보존하는 주체
    // Avatar = Character.   실제 월드에 있는 몸
    AbilitySystemComponent->InitAbilityActorInfo(PS, this);
}

Owner와 Avatar가 갈리는 게 핵심이다.

[리스폰 전]                     [리스폰 후]
Owner  = PlayerState  ────────  Owner  = PlayerState  (그대로)
Avatar = Character_A            Avatar = Character_B  (교체)
         └ 쿨타임·GE·Attribute는 Owner 쪽에 있으니 살아남는다

3번에서 PlayerState를 고른 이유가 코드로 드러나는 지점이다. 리스폰할 때 이 함수를 다시 불러 Avatar만 갈아 끼운다.

클라에서 Can't activate LocalOnly or LocalPredicted ability 에러가 나면 십중팔구 OnRep_PlayerState 경로를 빠뜨린 것이다. 이 에러는 4-3편에서 실제로 겪는다.

EPCharacter에도 ASC 필드가 있지만 PlayerState 것을 캐시한 포인터다. 두 개가 아니다.

// EPCharacter.h
UPROPERTY()
TObjectPtr<UAbilitySystemComponent> ASC;

의미상으로는 빌려온 포인터인데, UPROPERTY()가 붙어 있으니 GC는 강한 참조로 본다. 헷갈리기 쉬운 자리라 적어둔다. UPROPERTY를 빼면 GC가 이 참조를 모르고, 대상이 수거되면 댕글링이 된다.


5. AttributeSet

// Public/GAS/EPAttributeSet.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, Category = "Attribute|Health", ReplicatedUsing = OnRep_Health)
FGameplayAttributeData Health;
ATTRIBUTE_ACCESSORS(UEPAttributeSet, Health)

// 메타 Attribute. 복제하지 않는다 (4-2편에서 상세)
UPROPERTY(BlueprintReadOnly, Category = "Attribute|Meta")
FGameplayAttributeData IncomingDamage;
ATTRIBUTE_ACCESSORS(UEPAttributeSet, IncomingDamage)

매크로 한 줄이 GetHealth() / SetHealth() / InitHealth() / GetHealthAttribute() 네 개를 만든다.

float이 아니라 FGameplayAttributeData인 이유BaseValueCurrentValue를 나눠 갖기 때문이다. 버프가 CurrentValue만 바꾸고 사라지면 BaseValue로 돌아온다. 이 프로젝트는 아직 버프가 없지만 구조는 그대로 얻는다. 4-7의 이동속도 감소가 이 구조를 쓴다.

복제 매크로는 두 개가 짝이다

void UEPAttributeSet::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
    Super::GetLifetimeReplicatedProps(OutLifetimeProps);
    DOREPLIFETIME_CONDITION_NOTIFY(UEPAttributeSet, Health,    COND_None, REPNOTIFY_Always);
    DOREPLIFETIME_CONDITION_NOTIFY(UEPAttributeSet, MaxHealth, COND_None, REPNOTIFY_Always);
    // IncomingDamage는 복제하지 않는다
}

void UEPAttributeSet::OnRep_Health(const FGameplayAttributeData& OldValue)
{
    GAMEPLAYATTRIBUTE_REPNOTIFY(UEPAttributeSet, Health, OldValue);
}

REPNOTIFY_Always가 필요한 이유가 있다. 클라가 예측으로 HP를 이미 90으로 낮췄는데 서버도 90을 보내면, 기본 설정은 “값이 같다”고 OnRep을 건너뛴다. 그러면 GAS 내부 델리게이트가 안 울리고 UI가 갱신되지 않는다.

GAMEPLAYATTRIBUTE_REPNOTIFY 없이 빈 OnRep을 두면 값은 복제되는데 GAS가 변경을 인식하지 못한다. 증상이 “복제는 되는데 반응이 없음”이라 원인 찾기가 오래 걸린다.

OldValue를 받는 것도 이유가 있다. GAS는 값이 얼마인지가 아니라 얼마나 변했는지로 델리게이트를 굴린다. 이 두 줄이 마지막 편 HUD의 전제가 된다.

2단계에서 UEPItemInstanceMaxAmmo선언만 하고 복제 등록을 빠뜨려서 조용히 아무 일도 안 했던 적이 있다. UPROPERTYGetLifetimeReplicatedProps는 문법이 아니라 계약이다. 여기서도 같은 규칙이 적용된다.


6. NativeGameplayTags

// Public/GAS/EPNativeGameplayTags.h
namespace EmpGameplayTags
{
    EMPLOYMENTPROJ_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(TAG_State_Dead)
    EMPLOYMENTPROJ_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(TAG_State_Reloading)
    EMPLOYMENTPROJ_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(TAG_Event_Death)
    EMPLOYMENTPROJ_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(TAG_Data_Damage)
}
// Private/GAS/EPNativeGameplayTags.cpp
UE_DEFINE_GAMEPLAY_TAG(TAG_State_Dead,      "State.Dead")
UE_DEFINE_GAMEPLAY_TAG(TAG_State_Reloading, "State.Reloading")
UE_DEFINE_GAMEPLAY_TAG(TAG_Event_Death,     "Event.Death")
UE_DEFINE_GAMEPLAY_TAG(TAG_Data_Damage,     "Data.Damage")

FGameplayTag::RequestGameplayTag(FName("State.Dead")) 방식은 오타가 런타임까지 간다. 네이티브 선언은 오타가 컴파일 에러가 되고 자동완성도 된다. 엔진 시작 시 자동 등록되니 에디터에서 태그를 만들 필요도 없다.

이 선택이 실제로 값을 하는 순간이 4-6편에 나온다. HitZone.LimbHitZone.Limbs로 바꿨는데, 문자열이었다면 조용히 매칭 실패했을 것이 전부 컴파일 에러로 잡혔다.

태그 이름 규칙

시리즈 내내 유지한다.

접두사 의미
State.* 지속 상태 State.Reloading
Event.* 순간 신호 Event.Death
Ability.* GA 식별 Ability.Item.PrimaryUse
Cooldown.* 쿨타임 GE 표식 Cooldown.Weapon.PrimaryUse
Data.* SetByCaller 수치 주입 키 Data.Damage
HitZone.* 피격 부위 (4-4편) HitZone.Head

함정 정리

상황 원인 해결
클라 ASC가 null, 어빌리티 활성화 실패 OnRep_PlayerState에서 InitAbilityActorInfo 누락 서버·클라 양쪽에서 호출
Can't activate LocalOnly or LocalPredicted ability 위와 같음 클라 초기화 경로 확인
Attribute 복제 안 됨 GetLifetimeReplicatedProps 또는 OnRep 누락 두 매크로가 짝인지 확인
값은 복제되는데 UI 반응 없음 GAMEPLAYATTRIBUTE_REPNOTIFY 누락 OnRep 본문 확인
Mixed인데 GE가 소유 클라에도 안 옴 PlayerState의 Owner가 Controller가 아님 PossessedBy 경로 확인
GE를 적용해도 아무 일 없음 InitGlobalData() 미호출 GameInstance::Init
Attribute 복제가 느림 PlayerState 기본 NetUpdateFrequency 100으로 올린다

결과

PIE 데디케이티드 서버 + 클라 2인 기준으로 확인한 것:

  • 서버와 클라 양쪽에서 GetAbilitySystemComponent()가 null이 아니다
  • showdebug abilitysystem에서 Health = 100 복제 확인
  • Project Settings의 GameplayTags에 네이티브 태그가 자동 등록됨

showdebug abilitysystem은 이후 모든 편의 검증 도구가 된다. “태그가 붙었나”, “GE가 살아있나”, “쿨타임이 몇 초 남았나”를 전부 여기서 본다.

한계:

  • Attribute 초기값을 InitHealth(100.f) C++ 직접 호출로 처리했다. 레벨이나 직업별 초기값이 필요해지면 Instant GE나 DataTable로 바꿔야 한다. 지금은 HP가 고정값이라 제일 단순한 방법을 골랐다.
  • MaxHealth 버프가 없으므로 PreAttributeChange에서 비율 유지 로직을 넣지 않았다. RPG였다면 필요하다.

다음 편

TakeDamage()를 지운다. HP를 직접 깎는 대신 메타 어트리뷰트를 거쳐서 깎는 이유와, 사망을 어빌리티로 뺀 이유를 적는다.

3단계에서 만든 ConfirmHitscan은 한 줄도 안 바뀐다. 그 뒤 한 줄만 바뀐다.


참고

  • DOCS/Notes/04/04_GAS_DOCS.md 1~4장. 도입 배경과 아키텍처 결정
  • DOCS/Notes/04/04_GAS_01_Foundation.md. 구현 전체
  • GASDocumentation (tranek). ASC 배치와 Replication Mode 논의

DevLog 카테고리 내 다른 글 보러가기

댓글남기기