[Portfolio] GAS 기반 전투 시스템
카테고리: Portfolio
태그: C++, GameplayAbilitySystem, GAS, Prediction, UE5
📌 이 문서는 현재 구조를 정리한 것이며, 완성본이 아닙니다.
만들어 가는 과정은 개발 기록에 있습니다.
왜 옮겼나
GAS 이전에는 같은 규칙이 세 군데에 흩어져 있었다.
연사 속도 하나를 예로 들면, 클라이언트 입력 단계에서 한 번 막고, 서버 RPC에서 또 막고, 무기 액터가 세 번째로 막았다. 세 곳이 각자 다른 변수를 봤고, 그중 하나는 복제되지 않는 값이었다.
[이전]
Input → RequestFire() ← 클라 로컬 타이머로 1차 차단
→ Server_Fire RPC ← 서버 변수로 2차 차단
→ Weapon::CanFire() ← 무기 상태로 3차 차단
→ Weapon::Fire()
[현재]
Input → GA_Item_PrimaryUse 활성화
→ CommitAbility() ← 쿨다운 GE + 탄약 Cost 를 한 번에
→ SSR ConfirmHitscan
→ GE_Damage 적용
문제는 규칙이 세 벌인 것 자체가 아니라 어느 것이 진짜인지 코드만 봐서는 알 수 없다는 점이었다. GAS로 옮긴 실질적 이득은 기능이 아니라 상태가 사는 자리를 하나로 정한 것이다.
현재 구조
flowchart TD
PS[EPPlayerState] --> ASC[AbilitySystemComponent<br/>Mixed Replication]
ASC --> AS[EPAttributeSet]
ASC --> GA[GameplayAbility]
AS --> H[Health / MaxHealth]
AS --> D[IncomingDamage<br/>메타 어트리뷰트]
AS --> AM[Ammo / MaxAmmo]
AS --> MS[MoveSpeedMultiplier]
GA --> P[GA_Item_PrimaryUse]
GA --> R[GA_Item_Reload]
GA --> S[GA_Skill_Base]
GA --> DE[GA_Death]
GA --> IN[GA_Interact]
ASC를 PlayerState에 둔 이유
캐릭터가 아니라 PlayerState에 붙였다. 익스트랙션 슈터라 캐릭터는 죽고 다시 스폰되지만 플레이어 데이터는 매치 내내 살아 있어야 한다. ASC가 캐릭터에 붙어 있으면 죽을 때마다 어트리뷰트와 어빌리티가 같이 사라진다.
InitAbilityActorInfo는 서버와 클라 양쪽에서 불러야 한다.
서버는 PossessedBy, 클라는 OnRep_PlayerState다. 한쪽만 하면 조용히 반쪽만 동작한다.
Replication Mode는 Mixed다. 멀티플레이어에서 GE는 소유 클라에만 보내고 GameplayCue는 전체에 보낸다.
데미지 파이프라인
Health를 직접 깎는 GE는 하나도 없다. 전부 IncomingDamage라는 메타 어트리뷰트를 거친다.
flowchart LR
GE[GE_Damage<br/>SetByCaller] --> ID[IncomingDamage +N]
ID --> PE[PostGameplayEffectExecute]
PE --> CL[Health -= N<br/>0으로 초기화]
CL --> Z{Health <= 0?}
Z -->|Yes| DT[State.Dead 태그 부여]
DT --> GDA[GA_Death 자동 활성화]
이렇게 한 이유는 세 가지다.
- 데미지 계산을 한 곳에 모을 수 있다. 방어력, 부위 배율, 무적 판정이 전부
PostGameplayEffectExecute한 자리에 들어간다 - Health는 서버에서만 바뀐다. 메타 어트리뷰트는 복제되지 않는다. 클라가 볼 일도 없다
- 사망 판정이 한 번만 일어난다. 여러 발이 동시에 도착해도 진입점이 하나다
State.Dead는 Infinite GE로 부여한다. 태그를 직접 AddLooseGameplayTag로 붙이면 복제되지 않는다.
GE로 붙여야 다른 클라이언트도 저 캐릭터가 죽었다는 걸 안다.
GA_Death는 아무도 직접 호출하지 않는다. State.Dead 태그가 붙으면 트리거로 켜진다.
덕분에 죽는 경로가 늘어나도(낙사, 지형 데미지) 호출부를 추가할 필요가 없다.
자세한 구현: 데미지와 HP 파이프라인
어빌리티
| 어빌리티 | 실행 정책 | 하는 일 |
|---|---|---|
GA_Item_PrimaryUse |
LocalPredicted | 발사. 쿨다운 GE로 연사 제한, 탄약 Cost |
GA_Item_Reload |
LocalPredicted | 재장전. State.Reloading 태그 + WaitDelay |
GA_Skill_Dash |
LocalPredicted | 대시 |
GA_Skill_Heal |
LocalPredicted | 시전 후 회복 |
GA_Skill_ShieldOn |
LocalPredicted | 지속형 방어 |
GA_Death |
ServerOnly | 사망 연출, 입력 차단 |
GA_Interact |
LocalPredicted | 상호작용, 루팅 |
무엇을 예측하고 무엇을 예측하지 않는가
기준은 하나다. 틀렸을 때 되돌릴 수 있는가.
| 예측한다 | 예측하지 않는다 |
|---|---|
| 총구 화염, 발사음 | 데미지 적용 |
| 애니메이션 재생 | 사망 |
| 쿨다운 시작 | 히트 판정 |
| 탄약 감소 | 아이템 소비 확정 |
되돌릴 수 없는 것을 예측하면 롤백할 때 화면이 튄다. 반대로 연출은 예측하지 않으면 RTT만큼 늦게 나와서 총이 무겁게 느껴진다.
스킬 베이스 클래스
스킬 3종을 각자 구현했더니 시전, 취소, 이동속도 감소가 세 번씩 복사됐다. 스킬이 늘어날수록 곱하기로 커지는 구조였다.
UEPGA_Skill_Base로 공통 흐름을 올리고 서브클래스는 OnCastComplete() 하나만 채우게 했다.
UPROPERTY(EditDefaultsOnly, Category = "Cast")
float CastTime = 0.f;
UPROPERTY(EditDefaultsOnly, Category = "Cast")
bool bInterruptibleOnDamage = false;
virtual void OnCastComplete() PURE_VIRTUAL(...);
ActivateAbility는 final이다. 서브클래스가 실수로 덮어써서 공통 흐름을 건너뛰는 걸 컴파일 단계에서 막는다.
자세한 구현: 발사와 재장전 이관 · 스킬 3종과 베이스 클래스
겪은 문제: 쿨타임이 5에서 4.5로, 다시 5로 되돌아간다
가장 오래 붙잡은 문제다.
클라에는 같은 GE가 두 개 생긴다
CommitAbility는 예측 창 안에서 쿨다운 GE를 적용한다.
그래서 클라이언트는 예측본을 하나 만든다. 그리고 잠시 뒤 서버가 만든 진짜 GE가 복제돼서 내려온다.
쿨다운 GE는 보통 스택 정책이 없어서 둘이 합쳐지지 않는다.
그 겹치는 구간 동안 GetActiveEffectsTimeRemainingAndDuration이 항목 두 개를 돌려준다.
왜 하필 정확히 원래 값으로 돌아오나
이게 진단의 실마리였다. 4.7이나 4.9가 아니라 항상 정확히 원래 값이었다.
두 GE의 시작 시각이 왕복 지연만큼 벌어져 있기 때문이다.
| 누가 찍었나 | 시작 시각 | |
|---|---|---|
| 예측본 | 클라가 자기 추정 서버 시각으로 | S0 - D |
| 서버본 | 서버가 진짜 서버 시각으로 | S0 + U |
U는 입력이 올라가는 지연, D는 결과가 내려오는 지연이다.
차이가 U + D, 곧 왕복 지연이 된다.
그리고 클라이언트가 아는 서버 시각은 D만큼 낮다.
AGameStateBase::OnRep_ReplicatedWorldTimeSecondsDouble이 서버가 보낸 시각과 자기 시각을 그냥 빼기 때문이다. 비행 시간을 되더해주는 항이 없다.
// GameStateBase.cpp
const double ServerWorldTimeDelta = ReplicatedWorldTimeSecondsDouble - World->GetTimeSeconds();
// 지연 보정 항이 없다
그래서 서버 GE가 도착했을 때 경과 시간을 계산하면 RTT - D - U = 0이 된다.
방금 시작한 것으로 읽혀서 남은 시간이 전체 지속시간으로 되돌아간다.
예측을 아예 못 하는 게 아니다
흔히 “GAS는 쿨다운을 예측하지 못한다”고 하는데, 정확히는 적용은 예측하고 시간 정합만 못 맞춘다. 엔진 문서가 예측 대상으로 GameplayEffect 적용을 직접 명시한다. 없는 건 지연 보정이다.
표시는 단조 감소로 막았다
표시값이 이전보다 커지지 못하게 고정했다.
if (LastShownRemaining >= 0.f)
Remaining = FMath::Min(Remaining, LastShownRemaining);
LastShownRemaining = Remaining;
되돌아가는 대신 왕복 지연 동안 숫자가 멈춘다. 끝나는 시각은 서버와 정확히 일치한다. 숫자가 튀는 것보다 잠깐 멈추는 쪽이 눈에 덜 띈다.
자세한 구현: HUD와 GAS 이관 결산
HUD
C++ 베이스 클래스에 로직을 두고 WBP 서브클래스에 레이아웃을 둔다. 디자이너가 위젯을 옮겨도 C++을 건드리지 않는다.
데이터를 받는 방식은 세 계층이다.
| 계층 | 방식 | 쓰는 곳 |
|---|---|---|
| 어트리뷰트 | GetGameplayAttributeValueChangeDelegate |
HP, 탄약 |
| 태그 | RegisterGameplayTagEvent |
재장전 중, 사망 |
| GE 남은 시간 | 폴링 | 쿨타임 숫자 |
앞의 둘은 이벤트로 받는다. 쿨타임만 폴링이다. 남은 시간은 매 프레임 줄어드는 연속값이라 “바뀌었다”는 이벤트가 없다.
이관 결산
지운 것
| 제거 대상 | 대체 |
|---|---|
AEPCharacter::HP / TakeDamage() / OnRep_HP() |
Health 어트리뷰트 + GE 파이프라인 |
Server_Fire / Server_Reload RPC |
GA_Item_PrimaryUse / GA_Item_Reload |
LastServerFireTime 외 연사 변수 3종 |
GE_FireCooldown |
AEPWeapon::CurrentAmmo / StartReload / 타이머 핸들 |
Ammo 어트리뷰트 + WaitDelay |
AEPWeapon::WeaponState enum |
State.Reloading 태그 |
BoneDamageMultiplierMap |
TagDamageMultiplierMap |
그대로 둔 것
이쪽이 더 중요하다.
| 유지 대상 | 이유 |
|---|---|
UEPServerSideRewindComponent 전체 |
호출 위치만 바뀌었다. 구조는 무변경 |
| Physics Asset 본별 히트박스 | 태그 시스템이 그 위에 얹혔을 뿐 |
| 스프레드 계산 | 개선하며 유지 |
GAS 이관은 전면 재작성이 아니었다. 어려운 부분, 그러니까 지연 보상과 본 단위 판정은 그대로 두고 상태를 관리하던 코드만 걷어냈다.
가장 크게 달라진 건 기능이 아니라 기능을 추가할 때 무엇을 열어야 하는가다.
이전에는 AEPCharacter와 UEPCombatComponent를 열었다. 지금은 GA 클래스 하나와 DataAsset을 만든다.
남은 숙제
- 쿨다운 지연 보정 미적용.
GE_FireCooldown지속시간이1/FireRate라 핑이 높을수록 실질 연사가 느려진다. 0.2초 쿨다운에 왕복 100ms면 발사 간격이 0.3초가 된다. 서버에서ExactPing/2만큼 깎되 위조 방지 상한이 필수다 - 발사와 착탄 연출이 아직 Multicast RPC다. GameplayCue로 옮기는 게 맞다. FX 에셋이
CombatComponent에 있어서 무기별로 갈리지 않는 것부터 고쳐야 한다 - Ability Batching 미적용. 발사 빈도가 높은 게임이라 적용 가치가 있다
- 재장전과 시전 애니메이션이 없다.
PlayMontageAndWait로 바꿔야 한다 GA_Death가 진행 중인 어빌리티를 취소하지 않는다
첫 항목은 경쟁 슈터라면 그냥 넘길 수 없는 문제다. 포트폴리오 범위에서 우선순위를 뒤로 뒀을 뿐이다.
참고
- GASDocumentation
Engine/Plugins/Runtime/GameplayAbilities/Public/GameplayPrediction.h상단 주석. 무엇이 예측되고 무엇이 안 되는지 목록이 있다
댓글남기기