[GAS] Unreal Engine 데미지 팝업 시스템 개선: 타격감을 위한 Dev Log

2026. 1. 16. 18:30·Dev./UE 언리얼 엔진

데미지 숫자가 화면에 뜬다. 24, 31, 28, 19…

빠르게 적을 공격하면 화면은 숫자의 홍수가 된다.

읽을 수 없다. “내가 지금 얼마나 데미지를 넣고 있는 거지?” 플레이어는 혼란스럽다.

 

디아블로 4를 켜본다. 보더랜드4 플레이 영상을 본다.

같은 연사 공격인데 숫자가 깔끔하게 보인다.

숫자가 튀어 오르고, 연속 타격은 하나의 숫자로 합쳐지며, 멀리 있는 적의 숫자는 작게 표시된다.

 

이 글은 단순히 작동하는 데미지 팝업 시스템을

플레이어가 정보를 직관적으로 파악할 수 있는 시스템으로 개선한 과정을 담고 있다.


1. 무엇이 부족했는가

기존 시스템의 구조

프로젝트의 데미지 팝업 시스템은 기본기가 탄탄했다.

오브젝트 풀링(Object Pooling, 위젯을 매번 생성하지 않고 미리 만들어둔 풀에서 꺼내 쓰는 방식)으로 성능을 확보했고, Unreliable RPC(도착을 보장하지 않는 대신 가벼운 네트워크 호출)로 멀티플레이 환경도 고려했다.

 

문제는 플레이어 경험에 있다.

증상 목록

증상
상세
직선 상승 애니메이션 숫자가 위로만 올라가며 딱딱한 느낌
숫자 폭탄 연사 무기 사용 시 0.5초 만에 수십 개의 숫자가 화면을 덮음
공간감 부재 멀리 있는 적을 타격해도 숫자 크기가 동일
숫자 겹침 광역기 사용 시 같은 위치에 숫자가 쌓여 하나만 보임

기존 코드를 살펴보면 문제의 원인이 명확해진다.

// 기존 애니메이션: 단순 위로 상승
const float YOffset = Progress * FloatSpeed * TotalDuration;
CurrentPosition.Y -= YOffset;

// 기존 생성 로직: 매 타격마다 새 위젯
void ShowDamageNumber(float Damage, ...)
{
    Widget* NewWidget = GetPooledWidget(PC);
    NewWidget->ShowDamage(Damage, ...);  // 항상 새로운 숫자
}

이 시점에서 문제는 초기 설계에서 시각적 완성도를 고려하지 않은 결과였다.


2. 개선 목표 수립

AAA 게임들을 분석한 결과, 네 가지 핵심 요소를 도출했다.

목표 레퍼런스
포물선 애니메이션 보더랜드 스타일의 물리 기반 움직임
데미지 누적 디아블로 4의 연속 타격 합산
거리 기반 스케일링 원근법을 반영한 자연스러운 크기 변화
겹침 방지 광역 공격 시에도 모든 숫자가 보이는 구조

 


3. 해결 과정: 네 가지 핵심 기능 구현

3.1 포물선 애니메이션

왜 직선 애니메이션이 어색한가

사람의 눈은 자연스러운 움직임에 익숙하다.

공을 던지면 포물선을 그린다.

숫자가 "튀어 오른다"는 느낌을 주려면 중력이 필요하다.

핵심 공식

위치 = 초기위치 + 초기속도 × 시간 + 0.5 × 중력 × 시간²

이를 코드로 옮기면 다음과 같다.

// 수직 위치: 위로 튀어올랐다가 중력에 의해 떨어짐
CurrentPosition.Y = StartPosition.Y 
    + (InitialVelocity.Y * ElapsedTime)
    + (0.5f * Gravity * ElapsedTime * ElapsedTime);

// 수평 위치: 등속 운동 (공기 저항 무시)
CurrentPosition.X = StartPosition.X 
    + (InitialVelocity.X * ElapsedTime);

초기 속도에 랜덤성 부여

같은 위치에서 발생한 숫자들이 자연스럽게 흩어지도록 수평 속도를 랜덤하게 설정한다.

void UGS_DamageNumberWidget::ShowDamage(...)
{
    // 수평 속도: -80 ~ +80 픽셀/초 (왼쪽 또는 오른쪽으로 흩어짐)
    const float RandomHorizontal = FMath::RandRange(-HorizontalSpeedRange, HorizontalSpeedRange);
    
    // 수직 속도: 위쪽 방향 (스크린 좌표계에서 Y+는 아래이므로 음수)
    float UpwardSpeed = -InitialUpwardSpeed;
    
    // 크리티컬 히트는 더 극적으로
    const float SpeedMultiplier = (Type == EDamageNumberType::Critical) 
        ? CriticalSpeedMultiplier  // 1.4배
        : 1.0f;
    
    InitialVelocity.X = RandomHorizontal * SpeedMultiplier;
    InitialVelocity.Y = UpwardSpeed * SpeedMultiplier;
}

핵심 포인트

스크린 좌표계에서 Y+는 아래 방향이다. 따라서 중력값은 양수, 위로 튀어오르는 초기 속도는 음수가 된다.

이 부분을 혼동하면 숫자가 아래로 떨어지기만 하는 버그가 발생한다.

“현실적인 물리가 아니라 과장된 물리가 게임에서는 더 좋다. 약간 느리게 떨어지고, 약간 더 높이 튀어야 눈에 잘 들어온다.”


3.2 데미지 누적 시스템

문제 상황 재현

궁수 캐릭터 Merci로 연사 공격을 시작한다. 0.5초 후 화면 상태:

    24   31   28   19   22   35   27   30
      23   29   18   33   26   21   32

플레이어는 총 데미지가 얼마인지 알 수 없다.

설계 결정

같은 대상을 짧은 시간 내에 여러 번 타격하면 기존 숫자에 합산한다.

합산될 때마다 펄스(Pulse) 효과로 시각적 피드백을 제공한다.

구현 단계

Step 1: 타겟 추적을 위한 약한 참조 추가

// GS_DamageNumberWidget.h
private:
    TWeakObjectPtr<AActor> TargetActor = nullptr;
    float TotalAccumulatedDamage = 0.f;

TWeakObjectPtr(약한 포인터, 대상 객체가 파괴되면 자동으로 nullptr이 되는 포인터)를 사용하는 이유가 있다.

일반 포인터를 사용하면 적이 죽은 후에도 위젯이 해당 메모리를 참조하려 해서 크래시가 발생할 수 있다.


Step 2: 기존 위젯 검색

UGS_DamageNumberWidget* FindActiveWidgetForTarget(AActor* TargetActor) const
{
    for (UGS_DamageNumberWidget* Widget : ActiveWidgets)
    {
        if (IsValid(Widget) && Widget->IsActive() && Widget->GetTargetActor() == TargetActor)
        {
            return Widget;
        }
    }
    return nullptr;
}

Step 3: 누적 로직

void ShowDamageNumberInternal(float Damage, ..., AActor* TargetActor)
{
    if (bEnableAccumulation && TargetActor != nullptr)
    {
        UGS_DamageNumberWidget* ExistingWidget = FindActiveWidgetForTarget(TargetActor);
        if (ExistingWidget && ExistingWidget->AddDamage(Damage))
        {
            return;  // 기존 위젯에 누적 성공, 새 위젯 불필요
        }
    }
    
    // 누적 불가능하면 새 위젯 생성
    Widget* NewWidget = GetPooledWidget(PC);
    NewWidget->SetTargetActor(TargetActor);
    NewWidget->ShowDamage(Damage, ...);
}

Step 4: AddDamage 함수와 펄스 효과

bool UGS_DamageNumberWidget::AddDamage(float AdditionalDamage)
{
    if (!bIsActive || AdditionalDamage <= 0.f)
        return false;
    
    TotalAccumulatedDamage += AdditionalDamage;
    UpdateDamageText(TotalAccumulatedDamage);
    
    // 펄스 효과: 숫자가 잠깐 커졌다가 원래 크기로
    AccumulationPulseTime = 0.f;
    
    // 애니메이션 시간 연장 (연사 중에는 숫자가 계속 보여야 함)
    const float RemainingTime = TotalDuration - ElapsedTime;
    if (RemainingTime < 0.5f)
    {
        TotalDuration = ElapsedTime + 0.5f;
    }
    
    return true;
}

왜 펄스 효과가 필수인가

내부 상태가 변했는데 화면에 아무 변화가 없으면 플레이어는 모른다.

숫자가 심장 박동처럼 커졌다 작아지면 "데미지가 쌓이고 있다!"는 직관적인 피드백이 된다.

“UI에서 상태 변화는 반드시 시각적으로 표현해야 한다. 그렇지 않으면 존재하지 않는 것과 같다.”

 


3.3 거리 기반 스케일링 — 원근법 적용

문제

3D 공간에서 멀리 있는 적을 타격했는데 숫자 크기가 같으면 2D UI처럼 느껴진다.

해결

카메라와의 거리에 따라 숫자 크기를 조절한다.

const float Distance = FVector::Dist(WorldLocation, LocalPC->GetPawn()->GetActorLocation());

// 거리 500 → 스케일 1.2 (가깝고 크게)
// 거리 3000 → 스케일 0.6 (멀고 작게)
DistanceScale = FMath::GetMappedRangeValueClamped(
    FVector2D(NearDistance, FarDistance),       // 입력 범위
    FVector2D(MaxDistanceScale, MinDistanceScale), // 출력 범위
    Distance
);

FMath::GetMappedRangeValueClamped는 입력 범위를 출력 범위로 선형 변환하면서,

범위를 벗어나지 않도록 클램핑(제한)한다.

거리가 500 이하면 1.2, 3000 이상이면 0.6으로 고정된다.


3.4 스마트 겹침 방지 — 광역기 대응

문제 상황

광역기로 5명의 적을 동시에 타격한다.

모든 숫자가 같은 화면 좌표에 겹쳐서 하나만 보인다.

설계 결정

풀 물리 시뮬레이션은 과하다.

생성 시점에 한 번만 겹침을 검사하고, 겹치면 위로 밀어내는 간단한 방식을 선택했다.

if (bEnableOverlapPrevention)
{
    const float OverlapRadiusSq = FMath::Square(OverlapDetectionRadius);
    const int32 MaxOverlapIterations = 5;  // 무한 루프 방지
    
    for (int32 Iteration = 0; Iteration < MaxOverlapIterations; ++Iteration)
    {
        bool bFoundOverlap = false;
        
        for (UGS_DamageNumberWidget* ExistingWidget : ActiveWidgets)
        {
            if (!IsValid(ExistingWidget) || !ExistingWidget->IsActive())
                continue;
            
            const FVector2D ExistingPos = ExistingWidget->GetCurrentScreenPosition();
            const float DistanceSq = FVector2D::DistSquared(ScreenPosition, ExistingPos);
            
            if (DistanceSq < OverlapRadiusSq)
            {
                ScreenPosition.Y -= OverlapYOffset;  // 35픽셀 위로
                bFoundOverlap = true;
                break;
            }
        }
        
        if (!bFoundOverlap) break;
    }
}

최적화 포인트

거리 비교 시 sqrt() 함수를 호출하지 않는다. 제곱끼리 비교하면 결과는 같고 연산 비용은 줄어든다.

게임에서 매 프레임 수십 번 호출되는 함수라면 이런 미세 최적화가 쌓여서 체감된다.


4. 버그 수정 및 방어적 프로그래밍

4.1 Division by Zero 방지

// 위험한 코드
const float Progress = ElapsedTime / TotalDuration;  // TotalDuration이 0이면?

// 안전한 코드
const float SafeDuration = FMath::Max(TotalDuration, KINDA_SMALL_NUMBER);
const float Progress = ElapsedTime / SafeDuration;

KINDA_SMALL_NUMBER는 Unreal Engine에서 제공하는 상수로 약 0.0001이다.

0으로 나누는 크래시를 막으면서도 계산 결과에 영향을 주지 않는다.


4.2 풀에서 무효한 위젯 처리

// 문제: if문 하나로 끝나면 유효한 위젯을 못 찾을 수 있음
if (WidgetPool.Num() > 0)
{
    Widget* Widget = WidgetPool.Pop();
    if (IsValid(Widget)) return Widget;
}

// 해결: while 루프로 유효한 위젯을 찾을 때까지 반복
while (WidgetPool.Num() > 0)
{
    Widget* Widget = WidgetPool.Pop();
    if (IsValid(Widget)) return Widget;
}

4.3 델리게이트 중복 바인딩 방지

// 항상 해제 후 바인딩
Widget->OnAnimationComplete.Unbind();
Widget->OnAnimationComplete.BindUObject(this, &Callback);

기존 바인딩이 남아있는 상태에서 새로 바인딩하면 예측 불가능한 동작이 발생할 수 있다.


4.4 코드 중복 제거

ShowDamage()와 AddDamage()에서 동일한 텍스트 포맷팅 로직이 있었다. 헬퍼 함수로 추출했다.

void UGS_DamageNumberWidget::UpdateDamageText(float DamageAmount)
{
    if (!DamageText) return;
    
    FString DisplayText = (CurrentType == EDamageNumberType::Heal)
        ? FString::Printf(TEXT("+%d"), FMath::RoundToInt(DamageAmount))
        : FString::Printf(TEXT("%d"), FMath::RoundToInt(DamageAmount));
    
    DamageText->SetText(FText::FromString(DisplayText));
}

 


5. 결과 및 개선 효과

Before / After 비교

항목 Before  After
애니메이션 직선 상승 포물선 + 랜덤 분산
연사 시 숫자 수십 개 난립 타겟당 1개로 누적
원거리 타격 동일 크기 거리에 따른 자연스러운 스케일
광역 공격 숫자 겹침 자동 위치 조정
크래시 가능성 0 나눗셈, nullptr 위험 방어 코드 적용

6. 정리

체크리스트: 데미지 팝업 시스템 구현 시

  • [ ] 오브젝트 풀링 적용 여부
  • [ ] 물리 기반 애니메이션 (포물선) 적용
  • [ ] 연속 타격 누적 시스템 및 시각적 피드백
  • [ ] 거리 기반 스케일링
  • [ ] 겹침 방지 로직
  • [ ] 0 나눗셈, nullptr 등 방어 코드
  • [ ] 설정값을 UPROPERTY로 노출하여 디자이너 조정 가능하게

핵심 Unreal Engine 패턴

패턴 적용
오브젝트 풀링 위젯 생성/파괴 비용 제거
TWeakObjectPtr 순환 참조 방지, 자동 nullptr
FMath::GetMappedRangeValueClamped 범위 변환 + 클램핑
제곱 비교 sqrt() 회피로 성능 확보

다음 프로젝트를 위한 교훈

첫째, AAA 게임을 레퍼런스로 삼아 목표 수준을 먼저 정의한다.

둘째, 물리 기반 애니메이션은 고등학교 공식으로 충분하다.

셋째, 내부 상태 변화는 반드시 시각적으로 표현한다.

넷째, 최적화는 설계 단계부터 고려한다. 나중에 하면 구조 변경이 필요하다.

다섯째, 설정 가능한 파라미터로 만들어 디자이너가 직접 조정할 수 있게 한다.

저는 AI를 적극적으로 활용하는 개발자입니다. 코드 구현은 AI 도구와 협업하고, 저는 문제 분석, 기술 설계, 트러블슈팅, 최종 검증에 집중합니다.

모든 기술적 의사결정과 트러블슈팅은 제가 직접 수행한 것이며, AI는 그 과정을 가속화하는 도구였습니다. 이 블로그는 그 판단과 사고의 기록입니다.

"어떤 도구를 쓰느냐"보다 "어떤 문제를 해결하느냐"가 진짜 개발자의 가치라고 믿습니다.

I believe a developer's value lies in "what problems they solve," not "what tools they use."

'Dev. > UE 언리얼 엔진' 카테고리의 다른 글

언리얼 엔진 GAS(Gameplay Ability System)  (1) 2026.01.21
[GAS] Unreal Engine 1인칭/3인칭 카메라 전환 시스템  (1) 2026.01.19
[GAS] 1월 초 구현한 시스템 정리  (0) 2026.01.16
[GAS] Unreal Engine 죽음 시네마틱 카메라 시스템  (2) 2026.01.14
[GAS] 언리얼 엔진 피격 인디케이터 개발  (0) 2026.01.13
'Dev./UE 언리얼 엔진' 카테고리의 다른 글
  • 언리얼 엔진 GAS(Gameplay Ability System)
  • [GAS] Unreal Engine 1인칭/3인칭 카메라 전환 시스템
  • [GAS] 1월 초 구현한 시스템 정리
  • [GAS] Unreal Engine 죽음 시네마틱 카메라 시스템
raindrovvv
raindrovvv
raindrovvv 님의 블로그 입니다.
  • raindrovvv
    raindrovvv 님의 블로그
    raindrovvv
  • 전체
    오늘
    어제
    • 분류 전체보기 (170) N
      • Dev. (163) N
        • AI 인공지능 (27)
        • UE 언리얼 엔진 (81) N
        • Unity 유니티 (0)
        • Wwise 와이즈 (7)
        • 게임 네트워크 (8)
        • 그래픽스 Graphics (22)
        • 프로젝트 (8)
        • 기타 개발 관련 (10)
      • Computer Science (0)
        • 하드웨어 HW (0)
        • 소프트웨어 SW (0)
        • 통신 (0)
        • 데이터 (0)
      • 블로그 (3)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    생산성
    네트워크
    에이전트
    언리얼엔진
    TA
    unrealengine
    Git
    Unreal
    트러블슈팅
    AI
    깃
    인디게임
    Wwise
    바이브코딩
    dev
    devlog
    게임개발
    UE
    언리얼
    그래픽스
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
raindrovvv
[GAS] Unreal Engine 데미지 팝업 시스템 개선: 타격감을 위한 Dev Log
상단으로

티스토리툴바