[UE5] 익스트랙션 슈터 4-1. GAS 도입: ASC는 PlayerState에 둔다

게시:     수정

카테고리:

태그: , ,

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


왜 옮겼나

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

막힌 것 당시 코드 실제로 겪은 증상
상태가 흩어짐 WeaponState enum, HP UPROPERTY, LastServerFireTime이 각자 논다 같은 검증을 여러 곳에서 중복
복제 한계 “재장전 중”에 해당하는 개념이 없다 다른 클라가 그 상태를 알 수 없다
확장 비용 무기나 스킬을 추가하면 Character를 연다 사이드이펙트
수치 관리 탄약, HP, 쿨타임이 각각 UPROPERTY 복제 전략이 제각각
3단계에서 직접 관리하던 HP (접기/펼치기)
// 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. 모듈 추가

Build.cs 모듈 추가 (접기/펼치기)
// EmploymentProj.Build.cs
PublicDependencyModuleNames.AddRange(new string[] {
    "GameplayAbilities", "GameplayTags", "GameplayTasks"
});

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

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

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에는 이미 킬 수 같은 영속 데이터가 있으니 자연스러운 확장 위치이기도 하다.

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는 서버와 클라 양쪽에서

가장 흔한 실수 지점이다.

서버, 클라 양쪽의 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 것을 캐시한 포인터다. 두 개가 아니다.

Character가 들고 있는 캐시 포인터 (접기/펼치기)
// EPCharacter.h
UPROPERTY()
TObjectPtr<UAbilitySystemComponent> ASC;

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


5. AttributeSet

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

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

복제 등록과 OnRep (접기/펼치기)
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단계에서 UEPItemInstance와 MaxAmmo가 선언만 하고 복제 등록을 빠뜨려서 조용히 아무 일도 안 했던 적이 있다. UPROPERTY와 GetLifetimeReplicatedProps는 문법이 아니라 계약이다. 여기서도 같은 규칙이 적용된다.


6. NativeGameplayTags

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.Limb을 HitZone.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 카테고리 내 다른 글 보러가기

댓글남기기