[UE5] 추출 슈터 4-1. GAS 도입: ASC를 어디에 둘 것인가
카테고리: DevLog
📌 EmploymentProj 4단계 GAS 이관 첫 번째 글입니다. 👾 깃허브 · 📚 시리즈 목차 · ← 3-3. 예측 이펙트, 그리고 죽어 있던 코드 한 줄
왜 옮겼나
3단계까지 히트스캔, 본 단위 판정, 지연 보상을 전부 직접 만들었다. 동작은 했다.
문제는 기능을 하나 붙일 때마다 AEPCharacter와 UEPCombatComponent가 같이 부푼다는 것이었다.
| 막힌 것 | 당시 코드 | 실제로 겪은 증상 |
|---|---|---|
| 상태가 흩어짐 | 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인 이유는 BaseValue와 CurrentValue를 나눠 갖기 때문이다.
버프가 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단계에서
UEPItemInstance와MaxAmmo가 선언만 하고 복제 등록을 빠뜨려서 조용히 아무 일도 안 했던 적이 있다.UPROPERTY와GetLifetimeReplicatedProps는 문법이 아니라 계약이다. 여기서도 같은 규칙이 적용된다.
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.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.md1~4장. 도입 배경과 아키텍처 결정DOCS/Notes/04/04_GAS_01_Foundation.md. 구현 전체- GASDocumentation (tranek). ASC 배치와 Replication Mode 논의
댓글남기기