[UE5] 추출 슈터 4-6. HUD, 그리고 GAS 이관 결산

게시:     수정

카테고리:

태그: , ,

📌 EmploymentProj 4단계 GAS 이관 마지막 글입니다. 👾 깃허브 · 📚 시리즈 목차 · ← 4-5. 스킬 시스템과 예측 레이스 버그


원칙 하나

앞의 다섯 편에서 만든 상태가 전부 GAS 안에 있다. UI가 게임 로직을 다시 계산하면 안 된다. 구독만 한다.

UI는 이 시리즈에서 유일하게 읽기 전용 계층이다. UI가 상태를 판단하기 시작하면 GAS와 진실이 두 벌이 된다.

4-2편에서 HP UPROPERTY를 지우면서 OnRep_HP → UI 갱신 경로도 같이 사라졌다. 이 편이 그 자리를 실제로 채운다.


1. C++ 베이스 + WBP 서브클래스

UPROPERTY(meta = (BindWidget))
TObjectPtr<UProgressBar> CooldownBar;

WBP에 같은 이름, 같은 타입의 위젯을 배치하면 자동 연결된다. 이름이 다르면 WBP 컴파일 에러라 오타를 에디터가 잡아준다.

  담당
C++ (UEPSkillSlotWidget) 태그 구독, 상태 판정, 남은 시간 계산
WBP (WBP_SkillSlot) 레이아웃, 텍스처, 색상

색상까지 C++에 두지 않았다. 전부 EditAnywhere로 노출해 리컴파일 없이 만진다.

UPROPERTY(EditAnywhere, Category = "Style")
FLinearColor CooldownFillColor = FLinearColor(1.f, 0.5f, 0.f, 1.f);       // 주황
UPROPERTY(EditAnywhere, Category = "Style")
FLinearColor LockedCenterColor = FLinearColor(0.8f, 0.05f, 0.05f, 0.45f); // 반투명 빨강

🚩 BindWidgetOptional은 이름이 틀려도 컴파일이 통과하고 조용히 null이 된다. 필수 위젯에는 쓰지 않는다. 이 프로젝트에서는 “잠금 대각선”처럼 의도적으로 없앨 수 있는 요소에만 썼다.


2. 데이터 소스 세 계층

계층 API 용도
Attribute ASC->GetGameplayAttributeValueChangeDelegate(Attr).AddUObject(...) Health / MaxHealth / Ammo / MaxAmmo
Tag ASC->RegisterGameplayTagEvent(Tag, NewOrRemoved).AddUObject(...) State.Reloading 표시, 쿨타임과 잠금
복제 변수 AEPGameState::GetRemainingTime() 라운드 타이머

Attribute 델리게이트가 클라에서 동작하는 근거는 4-1편에서 이미 깔아뒀다. OnRepGAMEPLAYATTRIBUTE_REPNOTIFY가 브로드캐스트를 담당하고, REPNOTIFY_Always 덕분에 예측으로 값이 같아도 스킵되지 않는다.

4-1편에서 매크로 두 줄을 왜 쌍으로 써야 하는지 적었는데, 그게 여기서 값을 한다.

그리고 HP와 탄약을 같은 코드로 구독한다. 4-3편에서 탄약을 무기 Actor의 uint8에서 Attribute로 옮긴 이유가 이것이다.


3. 폴링할 수밖에 없는 하나

태그 이벤트는 켜짐과 꺼짐만 알려준다. “쿨타임 3.2초 남음” 같은 숫자는 알 수 없다.

// 쿨타임이나 Active 상태일 때"만" Tick에서 쿼리
if (bCoolingDown || bActive)
{
    // Key = 남은 시간, Value = 전체 Duration
    TPair<float, float> TimeRemainingAndDuration = /* GetActiveEffectsTimeRemainingAndDuration(Query) */;
    const float Percent = 1.f - (TimeRemainingAndDuration.Key / TimeRemainingAndDuration.Value);
    CooldownBar->SetPercent(Percent);
}

규칙: 태그 이벤트로 on/off를 토글하고, 켜져 있는 동안에만 Tick에서 숫자를 읽는다. 슬롯 3개가 해당 상태일 때만이니 비용은 무시할 수준이다. 상시 폴링은 하지 않는다.

GetActiveEffectsTimeRemainingAndDuration의 Pair 순서가 헷갈린다. Key가 남은 시간, Value가 전체 Duration이다. 반대로 쓰면 게이지가 이상하게 움직인다.


겪은 문제: 쿨타임이 5 → 4.5 → 5로 되돌아간다

숫자를 띄우자마자 나왔다. 쿨타임 5초짜리 스킬을 쓰면 5에서 4.5까지 내려가다가 다시 5로 튄다. 시전 게이지도 같았다. 한 번 튀고 나면 그 뒤로는 멀쩡하다.

처음엔 위젯 버그를 의심했다. 아니었다. GAS의 알려진 한계다.

클라에는 같은 GE가 두 개 생긴다

쿨다운 GE는 CommitAbility 안에서 활성화 윈도우 중에 적용되므로 예측 대상이다. 클라가 자기 것을 하나 만들고, 서버가 만든 것이 나중에 복제로 또 온다.

문제는 두 GE의 시작 시각이 다르다는 것이다.

// 엔진 GameplayEffect.cpp:4295. 클라와 서버가 똑같이 실행하는 줄
AppliedActiveGE = new(GameplayEffects_Internal)
    FActiveGameplayEffect(NewHandle, Spec, GetWorldTime(), GetServerWorldTime(), InPredictionKey);

GetServerWorldTime()이 서버에서는 진짜 값이지만, 클라에서는 추정치다. 그 추정치가 이렇게 만들어진다.

// 엔진 GameStateBase.cpp:169
const double ServerWorldTimeDelta = ReplicatedWorldTimeSecondsDouble - World->GetTimeSeconds();

ReplicatedWorldTimeSecondsDouble서버가 보낼 때의 서버 시각이고, World->GetTimeSeconds()클라가 받을 때의 클라 시각이다. 그 사이에 다운링크 지연이 껴 있는데 빼주는 항이 없다. 평균과 스무딩만 있고 지연 보정이 없다.

결론: 클라가 아는 서버 시각은 항상 다운링크 지연만큼 뒤처져 있다.

왜 하필 정확히 원래 값으로 돌아오나

이게 진단의 결정적 단서였다. 4.7도 4.9도 아니고 매번 딱 처음 값이었다.

서버본이 도착하면 시작 시각을 로컬 시계로 되돌린다.

// 엔진 GameplayEffect.cpp:2948
void FActiveGameplayEffect::RecomputeStartWorldTime(const float WorldTime, const float ServerWorldTime)
{
    StartWorldTime = WorldTime - (ServerWorldTime - StartServerWorldTime);
}

괄호 안이 “이 GE가 얼마나 살았나”다. 도착 시점에 서버본은 실제로 다운링크 지연 D만큼 살아 있다. 그런데 ServerWorldTime이 바로 그 D만큼 작다. 둘이 상쇄돼서 경과 시간이 0으로 계산된다.

서버본은 "방금 시작한 것"으로 취급된다. 도착 순간 남은 시간 = 전체 Duration.

그리고 서버본이 도착하기까지 걸리는 시간이 왕복 지연 RTT다.

쿨다운 5.0, RTT 0.5

t=0.0   예측본 적용                     표시 5.0
t=0.5   예측본 남은 시간 4.5
        서버본 도착. 남은 시간 5.0       표시 5.0   ← 튄다

내려간 만큼이 RTT, 튀어 오르는 지점이 전체 Duration. 그래서 항상 정확히 처음 값이다. 위젯이 둘 중 큰 쪽을 고르는 것도 한몫하지만, 작은 쪽을 골라도 소용없다. 예측 키가 catch-up되면 예측본이 제거되고 어차피 서버본만 남는다.

해결은 단조 감소 강제

// EPSkillSlotWidget.cpp:85
if (LastShownRemaining >= 0.f)
    Remaining = FMath::Min(Remaining, LastShownRemaining);
LastShownRemaining = Remaining;

표시값이 절대 늘지 않게 한다. 실제로 뭘 하는지 계산해보면

t=0.5   min(5.0, 4.5) = 4.5    역행하지 않는다
t=0.6   min(4.9, 4.5) = 4.5    대신 멈춘다
t=1.0   min(4.5, 4.5) = 4.5    서버본이 따라잡는다
t=1.1   min(4.4, 4.5) = 4.4    이때부터 서버본을 정직하게 따라간다

역행이 RTT짜리 정지로 바뀐다. 그리고 끝나는 시각은 서버와 정확히 일치한다. 짧은 쿨다운에서 정지가 눈에 띄는 게 대가인데, 역행보다 낫고 마지막이 정직해서 이걸 골랐다.

🐛 RecomputeState()가 상태 변화 없이도 ApplyState()를 부르고, ApplyState()LastShownRemaining을 리셋한다. 쿨타임 도중에 관계없는 태그 하나만 들락거려도 클램프가 풀려 그 프레임에 튐이 보인다. 아래 “알면서 남긴 것”에 적은 불필요한 캐시 리셋이 이 캐시다.

예측을 아예 못 하는 건 아니다

엔진 주석이 예측 대상을 직접 나열한다.

// 엔진 GameplayPrediction.h:35
What do we currently predict?
 -Initial GameplayAbility activation
 -GameplayEffect application:
     -Attribute modification (EXCEPTIONS: Executions do not currently predict)
     -GameplayTag modification

Some things we don't predict:
 -GameplayEffect removal
 -GameplayEffect periodic effects

적용은 예측된다. 못 하는 건 시각 정합이다. “쿨다운은 예측이 안 된다”는 말을 “클라에 GE가 안 생긴다”로 이해하면 틀린다. 생긴다.

이 차이가 게임플레이에 그대로 남는다. 어빌리티 재사용 게이트는 결국 서버본의 태그가 사라져야 열리므로, 핑이 높은 플레이어는 짧은 쿨다운 스킬의 실질 연사가 느리다. 표시만 고친다고 없어지지 않는다. GAS 문서도 같은 얘기를 하고, 포트나이트는 쿨다운 GE를 안 쓰고 자체 기록을 쓴다고 적혀 있다.

스킬은 쿨다운이 초 단위라 RTT가 비율로 묻힌다. 문제는 발사다.

// EPGA_Item_PrimaryUse.cpp:112. 4-3편에서 LastServerFireTime을 대체한 그 GE
const float Duration = Weapon ? (1.f / Weapon->WeaponDef->FireRate) : 0.2f;

연사속도 검증도 쿨다운 GE로 바꿔놨으니 똑같이 걸린다. 0.2초 쿨다운에 RTT 100ms면 실질 발사 간격이 0.3초다. 연사가 33% 느려진다. 이건 표시 문제가 아니라 실제 DPS 차이고, 핑이 높을수록 불리해진다.

제대로 고치려면 서버가 GE를 적용할 때 ExactPing/2만큼 지속시간을 깎아야 한다. 핑을 위조하면 쿨다운이 짧아지므로 상한이 필수다. 3단계에서 MaxRewindSeconds를 건 것과 같은 이유다. 안 했다. 우선순위에서 밀렸을 뿐이고, 아래 “남은 숙제”에 적어둔다.


4. 초기화 타이밍

4-3편에서 겪은 “PIE 시작 직후 어빌리티 활성화 실패”와 같은 뿌리의 문제다.

클라이언트에서 OnRep_PlayerState와 OnRep_Controller 도착 순서는 비결정적이다.
→ 기존 코드는 양쪽 모두 InitASC()를 호출해 "나중에 도착한 쪽이 성공"하는 구조
→ HUD 바인딩도 같은 수렴 지점에 얹는다
// EPCharacter::InitASC() 말미
if (IsLocallyControlled())
    if (AEPPlayerController* PC = GetController<AEPPlayerController>())
        PC->InitHUD(ASC);

PlayerController의 BeginPlay에서 하면 안 된다. 그 시점에 클라 ASC는 null일 수 있다. 4-1편에서 만든 InitASC()가 이 타이밍을 보장하는 유일한 지점이다.


5. ASC가 위젯보다 오래 산다

이 편에서 제일 놓치기 쉬운 부분이다.

ASC   → PlayerState 소속. 매치 내내 산다 (4-1편의 배치 결정)
위젯  → Character가 죽거나 HUD가 재생성되면 파괴된다

→ 위젯이 죽었는데 ASC가 콜백을 날리면 크래시
void UEPSkillSlotWidget::NativeDestruct()
{
    if (ASC.IsValid())
    {
        // 등록한 핸들을 전부 되돌린다
        ASC->RegisterGameplayTagEvent(CooldownTag, EGameplayTagEventType::NewOrRemoved).Remove(CooldownHandle);
        for (const FDelegateHandle& H : ActiveHandles) { /* ... */ }
        for (const FDelegateHandle& H : LockHandles)   { /* ... */ }
    }
    Super::NativeDestruct();
}

그리고 InitWithASC재진입 안전해야 한다. 리스폰 시 InitASC()가 다시 불리므로, 항상 기존 핸들을 먼저 제거하고 재바인딩한다. 같은 ASC여도 동일한 경로를 탄다.

리스폰이 잦은 게임에서는 재진입 안전이 기본 요구사항이다. 4-5편의 MoveSpeedMultiplier 델리게이트도 정확히 같은 패턴으로 처리했다.

AddUObject로 등록하면 this가 GC될 때 자동 해제되기는 한다. 하지만 위젯은 GC 전에 NativeDestruct로 먼저 논리적으로 죽으므로 명시적 해제가 맞다.


6. 스킬 슬롯 4-상태

enum class EEPSkillSlotState : uint8
{
    Ready,
    Cooldown,
    Active,    // 이 슬롯 자신의 스킬이 채널링 중이거나 지속 효과 유지 중
    Locked,    // 다른 스킬의 시전 때문에 잠김
};
상태 트리거 시각
Ready 아무 태그 없음 흰 테두리, 흰 중앙, 검은 픽토그램
Cooldown 자신의 Cooldown.Skill.* 주황이 아래에서 위로 차오름, 남은 초 표시
Active 자신의 ActiveTags 중 하나 주황이 전체를 고정으로 덮음
Locked 공용 State.Casting 불투명 빨강 테두리, 반투명 빨강 중앙, 대각선

우선순위는 Active > Locked > Cooldown > Ready다.

🐛 bActivebLocked보다 먼저 체크해야 한다. 힐 시전 중이면 State.Casting도 켜져 있는데, 자기 자신은 “잠김”이 아니라 “진행 중”이다. 순서를 틀리면 힐 슬롯이 자기 자신을 잠금으로 표시한다.

태그 하나로 채널링형과 지속형을 통합한다

GAS 관점에서 State.HealingState.Shielded완전히 다른 메커니즘이다.

  State.Casting / State.Healing State.Shielded
시점 효과 발동 (채널링) 효과 발동 (지속)
다른 스킬 잠금 잠근다 안 잠근다
메커니즘 GE_CastingClass GrantedTags 평범한 Duration GE GrantedTags

그런데 위젯 입장에서는 둘 다 “내 스킬이 지금 뭔가 하고 있다”로 같다. 그래서 ActiveTags라는 컨테이너 하나로 묶어 구독하고, 뭐든 하나 켜지면 똑같이 주황으로 덮는다.

위젯은 어떤 GAS 메커니즘에서 온 태그인지 몰라도 된다. 이게 “UI는 판단하지 않고 구독만 한다”의 구체적인 모습이다.

잠금은 반대로 전부 같은 태그 하나만 본다. 세 슬롯 전부 LockTags = { State.Casting }이다. 4-5편에서 공용 태그로 통일해둔 덕분에 새 스킬이 추가돼도 기존 슬롯 설정을 손댈 필요가 없다.


7. 게이지를 인터페이스로 분리한 이유

중앙 시전 게이지를 링으로 하드코딩하면, 나중에 막대나 호로 바꿀 때마다 C++을 고쳐야 한다.

UEPCastGaugeWidget          "State.Casting을 구독하고 남은 비율을 계산한다"
       │  SetGaugeProgress(Progress01)
       ▼
IEPGaugeVisual              "진행도 숫자 하나를 받아 그린다". 계약은 이것뿐
       ├── UEPMaterialGaugeWidget   머티리얼 마스크. 링이나 호는 머티리얼이 결정
       ├── UEPBarGaugeWidget        UMG 내장 ProgressBar
       └── (WBP 전용 커스텀 위젯)   BlueprintNativeEvent라 그래프만으로도 구현 가능
UINTERFACE(MinimalAPI, Blueprintable)
class UEPGaugeVisual : public UInterface { GENERATED_BODY() };

class EMPLOYMENTPROJ_API IEPGaugeVisual
{
    GENERATED_BODY()
public:
    // Progress01이 1이면 "방금 시작", 0이면 "곧 종료". 계약은 항상 "남은 비율" 고정.
    // 1→0으로 그릴지 0→1로 뒤집을지는 구현체의 bInvertProgress가 결정한다
    UFUNCTION(BlueprintNativeEvent, Category = "Gauge")
    void SetGaugeProgress(float Progress01);

    UFUNCTION(BlueprintNativeEvent, Category = "Gauge")
    void SetGaugeVisible(bool bVisible);
};

계약값을 “남은 비율”로 고정한 게 핵심이다. 로직은 방향을 모른다. 차오를지 줄어들지는 순수한 표시 취향이라 비주얼 쪽 옵션으로 밀어냈다.

// 비워두면 태그 추적은 그대로 하되 화면엔 아무것도 안 그린다
UPROPERTY(meta = (BindWidgetOptional))
TObjectPtr<UWidget> GaugeVisual;

“안 쓰기”까지 선택지에 넣었다. WBP_HUDCastGauge를 안 놓으면 기능 전체가 꺼지고, GaugeVisual만 비우면 로직은 살아있되 안 그린다.

머티리얼은 한 곳에만 썼다

  • 슬롯의 “아래에서 위로 차오름” → UMG ProgressBarFill Type = Bottom to Top으로 충분하다
  • 잠금 룩(반투명 빨강, 대각선) → 이미지를 역할별로 분리해 틴트 색의 알파로 처리했다. 대각선은 얇은 Image를 45도 회전시켰다
  • 중앙 시전 게이지(방사형 마스크) → UMG에 radial ProgressBar가 없어서 여기만 머티리얼이다

원칙: 머티리얼은 UMG 기본 기능으로 못 그리는 것에만 쓴다.

중앙 게이지가 스킬을 몰라도 되는 이유

UPROPERTY(EditAnywhere, Category = "Cast")
FGameplayTag ChannelTag;   // 항상 State.Casting. 스킬별 고유 태그가 아니다
  • 상호잠금 메커니즘상 한 번에 한 스킬만 이 태그를 가질 수 있다
  • 채널링형 스킬은 어차피 자기 GE_CastingClass에 이 태그를 넣어야만 상호잠금이 동작한다

그래서 나중에 어떤 채널링 스킬이 추가돼도 이 위젯은 코드도 설정도 안 바뀐다. 반대로 State.Shielded 같은 지속형 태그는 중앙 게이지에 절대 뜨지 않는다. 그건 슬롯 몫이다.


8. 안 만든 것

킬 피드 UI는 의도적으로 만들지 않았다. 킬 성공 시 킬러 본인에게만 사운드 하나를 재생한다.

// AEPPlayerController::Client_OnKill_Implementation (서버에서 킬러 개인에게, Reliable)
// 이미 있던 경로에 KillConfirmSound만 추가했다

GameState Multicast도, 새 위젯도 필요 없었다. 있는 경로에 두 줄 얹는 게 전부다.

타르코프식 정보 은폐가 이 게임의 축이라 킬 피드가 애초에 기획에 안 맞기도 한다. 1편에서 “남은 인원을 크게 띄우지 않는다”고 적은 것과 같은 이유다.


함정 정리

함정 설명
BindWidget 이름 불일치 WBP 컴파일 에러. 타입도 일치해야 한다
델리게이트 해제 누락 ASC가 위젯보다 오래 산다. NativeDestruct에서 Remove 필수
HUD 초기화를 PC BeginPlay에서 클라에서 ASC가 아직 null일 수 있다
리스폰 후 콜백 중복 InitWithASC가 재진입 안전하지 않음
Mixed 모드 착각 쿨타임 GE 쿼리는 소유 클라 전용. 타 플레이어 UI는 복제되는 태그만
Attribute 델리게이트가 클라에서 안 옴 REPNOTIFY_Always 누락
쿨타임 폴링 남용 태그로 on/off, 쿨타임 중일 때만 Tick 쿼리
Pair 순서 혼동 Key가 남은 시간, Value가 전체 Duration
쿨타임 숫자가 되돌아감 예측본과 서버본이 공존한다. 표시값을 단조 감소로 강제
단조 클램프가 가끔 풀림 ApplyState가 상태 변화 없이도 캐시를 리셋
오버레이가 옆으로 채워짐 Fill Type이 기본값이라 Bottom to Top으로 바꿔야 한다
슬롯이 빨강으로 안 바뀜 LockTags(컨테이너)와 CooldownTag(단일)를 혼동
대각선이 안 보임 BindWidgetOptional이라 이름이 틀려도 조용히 null
Heal 슬롯이 자기를 잠금으로 표시 bActivebLocked보다 먼저 체크해야 함
Shield 슬롯에 주황이 안 뜸 ActiveTags가 비어 있음. 지속형이라 {State.Shielded} 필요
새 채널링 스킬이 중앙 게이지에 안 뜸 ChannelTag를 스킬 고유 태그로 설정. 항상 공용 State.Casting

결과

PIE 2인 기준:

  • 피격 → 체력바가 즉시 반응
  • 발사와 재장전 → 탄약 숫자 반응, State.Reloading 표시 on/off
  • 스킬 사용 → 슬롯이 Cooldown으로, 주황이 아래에서 위로 차오름
  • 힐 시전 → 힐 슬롯은 Active, 나머지 두 슬롯은 Locked
  • 힐 시전 중 중앙 게이지가 줄어듦, 취소 시 즉시 사라짐
  • 리스폰 → HUD 재바인딩 정상, 콜백 중복 없음, 크래시 없음
  • 중앙 게이지 비주얼을 링에서 막대로 교체 → C++ 무변경으로 동작

한계:

  • 타 플레이어 머리 위 체력바가 없다. Mixed 모드 제약 때문에 태그나 Attribute OnRep을 경유해야 한다
  • 피격 방향 인디케이터와 데미지 숫자가 미구현이다. GameplayCue로 붙일 자리는 열려 있다
  • 크로스헤어가 아직 WBP_HUD로 통합되지 않고 별도 위젯이다

알면서 남긴 것:

  • EPSkillSlotWidget.cpp:143-149. 상태 변화가 없어도 ApplyState를 호출해 불필요한 캐시 리셋이 일어난다. 그 캐시가 쿨타임 역행을 막는 LastShownRemaining이라 무해하지 않다. if (NewState == CurrentState) return; 한 줄이면 막힌다
  • UEPCastGaugeWidget이라는 이름이 부정확하다. 방벽 지속시간 표시에도 쓰이면서 “Cast”가 안 맞게 됐다. 개명하려면 CoreRedirects가 필요해서 미뤘다

4단계 결산

지운 것

제거 대상 대체
AEPCharacter::HP / MaxHP / TakeDamage() / OnRep_HP() UEPAttributeSet::Health + GE 파이프라인 4-2
UEPCombatComponent::Server_Fire RPC GA_Item_PrimaryUse 4-3
UEPCombatComponent::LastServerFireTime GE_FireCooldown 4-3
UEPCombatComponent::Server_Reload RPC GA_Item_Reload 4-3
AEPWeapon::CurrentAmmo / StartReload / FinishReload / ReloadTimerHandle Ammo Attribute + WaitDelay 4-3
AEPWeapon::WeaponState (enum) State.Reloading GameplayTag 4-3
UEPWeaponDefinition::BoneDamageMultiplierMap TagDamageMultiplierMap 4-4
UEPPhysicalMaterial::bIsWeakSpot MaterialTags 4-4
Stamina 전체 (Attribute / GE / State.Dashing) 설계 변경으로 폐기 4-5

그대로 둔 것

이 표가 더 중요하다.

유지 대상 이유
UEPServerSideRewindComponent 전체 호출 위치만 CombatComponent에서 GA로. 구조 무변경
Physics Asset 본별 히트박스 태그 시스템이 그 위에 얹혔을 뿐
EP_TraceChannel_Weapon 무변경
UEPCombatComponent 코스메틱 헬퍼 GA가 호출하는 헬퍼로 유지
EEPBallisticType switch GA 내부로 이동만
AEPWeapon::Fire() 스프레드 계산 4-4편에서 CDF로 개선하며 유지

GAS 이관은 전면 재작성이 아니었다. 어려운 부분, 그러니까 지연 보상과 본 단위 판정은 그대로 두고 상태를 관리하던 코드만 걷어냈다.

가장 크게 달라진 건 기능이 아니라 “기능을 추가할 때 무엇을 열어야 하는가”다. 이전에는 AEPCharacterUEPCombatComponent를 열었다. 지금은 GA 클래스 하나와 DataAsset을 만든다.

남은 숙제

  • Ability Batching 미적용. 발사 빈도가 높은 게임이라 적용 가치가 있다
  • 재장전과 시전 애니메이션이 없다. PlayMontageAndWait로 바꿔야 한다
  • 발사와 착탄 연출이 아직 Multicast RPC다. GameplayCue로 옮기는 게 맞는데, 현재 FX 에셋이 CombatComponent에 있어서 무기별로 갈리지 않는다. 그것부터 옮겨야 한다
  • case Hitscan: default: 문제. 근접무기를 넣기 전에 처리해야 한다 (4-3편)
  • GA_Death가 진행 중인 어빌리티를 취소하지 않는다
  • 쿨다운 지연 보정 미적용. GE_FireCooldown 지속시간이 1/FireRate라 핑이 높을수록 실질 연사가 느려진다. 서버에서 ExactPing/2만큼 깎되 상한을 걸어야 한다

정직하게 적으면 셋째와 넷째는 지금 고치는 게 제일 싸다. 우선순위에서 밀렸을 뿐이다. 마지막 항목은 경쟁 슈터에서는 그냥 넘길 수 없는 문제인데, 포트폴리오 범위에서 우선순위를 뒤로 뒀다.


다음 단계

5단계는 루팅과 인벤토리다. 4-5편 말미에 적은 스킬 슬롯 로드아웃도 거기서 이어진다. 로드아웃은 장비나 인벤토리와 같은 서버 권위 데이터 계층이라, 인벤토리와 묶는 게 자연스럽다.

3단계까지는 직접 만든 시스템이었고, 4단계에서 그걸 GAS 위에 다시 세웠다. 5단계는 그 위에 아이템이 오가는 계층을 얹는다.


참고

  • DOCS/Notes/04/04_GAS_08_HUD.md. 구현 전체
  • DOCS/Notes/04/GAS_STATUS.md. 전체 진행 상황과 레거시 제거 검증
  • 엔진 GameplayAbility.cpp. GetActiveEffectsTimeRemainingAndDuration 사용례
  • 엔진 GameplayPrediction.h:22-264. 무엇을 예측하고 무엇을 안 하는지 주석으로 전부 적혀 있다
  • 엔진 GameplayEffect.cpp:2948, GameStateBase.cpp:164. 쿨타임 역행의 실제 원인
  • DOCS/Mine/CooldownPrediction.md. 시각 어긋남의 유도 과정 전체

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

댓글남기기