RPG, FPS, TPS 같은 장르에서는 플레이어 체력이 낮아졌을 때 위험 상태를 직관적으로 알려주는 연출이 자주 사용된다.
단순히 수치만 줄어드는 것보다, 화면 경고 UI와 사운드가 함께 출력되면 플레이어가 현재 상태를 훨씬 빠르게 인식할 수 있다.
이번에는 플레이어의 체력이 일정 수치 이하로 떨어졌을 때, 경고 Widget이 출력되고 심장박동 사운드가 재생되는 저체력 경고 시스템을 구현한 과정을 정리해보려고 한다.
이번 구현의 조건은 아래와 같다.
- Player의 체력이 20% 이하로 떨어지면 경고 Widget 출력
- Widget이 표시되는 동안 심장박동 사운드 재생
- 체력이 다시 20% 이상으로 회복되거나 사망하면 Widget과 사운드 제거
이번 구현은 우선 빠르게 동작하는 결과를 만드는 것을 목표로 했기 때문에, 최종적으로는 Player BP의 Tick 기반 검사 방식으로 연결했다.
구조적으로는 더 개선할 여지가 있지만, 저체력 경고가 어떤 식으로 동작하는지 정리하기에는 충분한 형태다.
1. 저체력 경고 Widget 구성
가장 먼저 플레이어에게 출력할 경고 UI를 만들고, Fade In, Fade Out, 반복 애니메이션, 심장박동 사운드를 함께 연동할 수 있도록 UUserWidget 기반 클래스를 작성했다.
이 Widget은 아래 역할을 담당한다.
- 생성 시 Fade In 애니메이션 재생
- Fade In이 끝나면 반복되는 경고 애니메이션 재생
- Widget이 열려 있는 동안 심장박동 사운드 재생
- 제거 시 Fade Out 애니메이션을 재생한 뒤 Widget 제거
즉, 단순히 UI 하나를 띄우는 것이 아니라, 위젯의 등장과 유지, 종료까지 하나의 흐름으로 처리하는 구조다.


PlayerLowHealth.h
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
|
#pragma once
#include "CoreMinimal.h"
#include "Blueprint/UserWidget.h"
#include "PlayerLowHealth.generated.h"
class UImage;
class UWidgetAnimation;
class USoundBase;
class UAudioComponent;
/** 플레이어 체력이 낮을 때 표시되는 경고 위젯 */
UCLASS()
class MyGame_API UPlayerLowHealth : public UUserWidget
{
GENERATED_BODY()
protected:
virtual void NativeConstruct() override;
virtual void NativeDestruct() override;
public:
UFUNCTION(BlueprintCallable, Category = "Animation")
void PlayCloseAnimationAndRemove();
protected:
UFUNCTION()
void OnOpenAnimationFinished();
UFUNCTION()
void OnCloseAnimationFinished();
protected:
UPROPERTY(Transient, meta = (BindWidgetAnim))
TObjectPtr<UWidgetAnimation> OpenLowHealthAnimation;
UPROPERTY(Transient, meta = (BindWidgetAnim))
TObjectPtr<UWidgetAnimation> CloseLowHealthAnimation;
UPROPERTY(Transient, meta = (BindWidgetAnim))
TObjectPtr<UWidgetAnimation> HeartBeatingAnimation;
UPROPERTY(meta = (BindWidget))
TObjectPtr<UImage> LowHealthBackGroundImage;
UPROPERTY(EditAnywhere, Category = "Sound")
TObjectPtr<USoundBase> HeartbeatSound;
UPROPERTY()
UAudioComponent* PlayingSoundComponent;
};
|
cs |
2. Widget 생성 시 애니메이션과 사운드 재생
NativeConstruct()에서는 Widget이 화면에 나타날 때 필요한 초기 동작을 처리했다.
- 심장박동 사운드를 재생하고 AudioComponent를 저장
- Fade In 애니메이션 종료 시점을 바인딩
- Fade In이 끝나면 반복용 애니메이션으로 전환
또한 NativeDestruct()에서는 위젯이 제거될 때 재생 중이던 사운드를 정리하도록 했다.
이렇게 해두면 Widget이 사라졌는데도 사운드가 계속 남아 있는 문제를 막을 수 있다.
PlayerLowHealth.cpp
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
|
#include "PlayerLowHealth.h"
#include "Animation/WidgetAnimation.h"
#include "Components/Image.h"
#include "Kismet/GameplayStatics.h"
#include "Components/AudioComponent.h"
void UPlayerLowHealth::NativeConstruct()
{
Super::NativeConstruct();
if (HeartbeatSound && !PlayingSoundComponent)
{
// 사운드를 재생하고 제어를 위해 AudioComponent를 변수에 저장
PlayingSoundComponent = UGameplayStatics::SpawnSound2D(GetWorld(), HeartbeatSound);
}
if (OpenLowHealthAnimation)
{
// FadeIn 애니메이션이 끝나면 OnOpenAnimationFinished 함수 호출
FWidgetAnimationDynamicEvent OnFadeInFinishedCallback;
OnFadeInFinishedCallback.BindUFunction(this, FName("OnOpenAnimationFinished"));
BindToAnimationFinished(OpenLowHealthAnimation, OnFadeInFinishedCallback);
PlayAnimation(OpenLowHealthAnimation);
}
else if (HeartBeatingAnimation)
{
PlayAnimation(HeartBeatingAnimation, 0.0f, 0);
}
}
void UPlayerLowHealth::NativeDestruct()
{
if (PlayingSoundComponent && PlayingSoundComponent->IsActive())
{
PlayingSoundComponent->Stop();
}
PlayingSoundComponent = nullptr;
Super::NativeDestruct();
}
void UPlayerLowHealth::PlayCloseAnimationAndRemove()
{
if (CloseLowHealthAnimation)
{
// FadeOut 애니메이션이 끝나면 OnCloseAnimationFinished 함수 호출
FWidgetAnimationDynamicEvent OnFadeOutFinishedCallback;
OnFadeOutFinishedCallback.BindUFunction(this, FName("OnCloseAnimationFinished"));
BindToAnimationFinished(CloseLowHealthAnimation, OnFadeOutFinishedCallback);
StopAllAnimations();
PlayAnimation(CloseLowHealthAnimation);
}
else
{
RemoveFromParent();
}
}
/** FadeIn 애니메이션이 끝났을 때 호출되는 함수 */
void UPlayerLowHealth::OnOpenAnimationFinished()
{
if (HeartBeatingAnimation)
{
// 무한 반복
PlayAnimation(HeartBeatingAnimation, 0.0f, 0);
}
}
/** FadeOut 애니메이션이 끝났을 때 호출되는 함수 */
void UPlayerLowHealth::OnCloseAnimationFinished()
{
RemoveFromParent();
}
|
cs |
이렇게 구성하면 Widget은 생성 직후 자연스럽게 등장하고, 열려 있는 동안에는 붉게 반짝이는 연출과 심장박동 소리를 유지하다가, 닫힐 때는 바로 사라지지 않고 Fade Out을 거쳐 제거된다.
3. Widget Blueprint에서 사운드와 애니메이션 연결
위 코드를 작성한 뒤에는 해당 WBP의 Class Defaults에서 심장박동 사운드를 지정해줄 수 있다.
즉, 코드에서는 사운드 재생 흐름만 담당하고, 실제 어떤 사운드를 쓸지는 에디터에서 설정하는 구조다.
이 방식의 장점은 아래와 같다.
- 코드 수정 없이 사운드 교체 가능
- UI 디자이너와 로직 작업 분리 가능
- 같은 Widget 클래스를 유지하면서도 다른 연출 테스트 가능
즉, 코드와 에셋 연결 지점을 느슨하게 유지하는 방식이라고 볼 수 있다.

4. 플레이어 체력과 저체력 Widget 연동
다음 단계에서는 플레이어 체력과 방금 만든 Widget을 연결해야 한다.
원래는 LowHealth 같은 Gameplay Tag를 만들어서, GAS에서 체력 Attribute 변화에 맞춰 이벤트 기반으로 처리할 계획이었다.
하지만 당시에는 빠르게 구현해야 했기 때문에, 우선 Player BP에서 Tick으로 체력을 검사하는 방식을 선택했다.
구성 방식은 아래와 같다.
- Player BP에 Update Low Health Warning Widget 함수 생성
- WBP_PlayerLowHealth 참조 변수 생성
- 현재 체력을 가져와 저체력 조건 검사
- 조건 충족 시 Widget 생성
- 조건 해제 시 Widget 제거
여기서 가장 중요한 점은 유효성 검사와 참조 관리다.
저체력 조건일 때
- 체력이 0 초과이고 20% 이하라면 Widget을 띄운다.
- 단, 이미 Widget이 존재하면 다시 만들지 않는다.
회복되거나 사망했을 때
- 체력이 20%를 초과하거나 0 이하라면 Widget을 제거한다.
- 제거 후에는 저장해둔 Widget 변수도 반드시 비워줘야 한다.
특히 Create Widget 이후에 참조 변수를 저장하지 않으면, 다음 검사 때마다 유효성 검사에서 계속 False로 판단되어 Widget이 여러 번 생성될 수 있다.
반대로 제거 시에는 Unset까지 해주지 않으면 이미 제거된 위젯을 여전히 살아 있는 것으로 인식할 수도 있다.
즉, 이 구조에서는 생성과 제거 그 자체보다 참조 관리가 더 중요하다고 볼 수 있다.



5. 이번 구현에서 Tick을 사용한 이유와 한계
이번 구현은 빠르게 결과를 확인해야 했기 때문에, 체력 검사 함수를 Event Tick에 연결하는 방식으로 처리했다.
이 방식은 구현 속도가 빠르고 테스트도 쉬운 편이지만, 구조적으로는 가장 이상적인 방법은 아니다.
왜냐하면 체력이 바뀌지 않은 프레임에서도 계속 함수가 호출되기 때문이다.
즉, 현재 방식은 아래 장단점이 있다.
장점
- 빠르게 구현 가능
- 디버깅이 쉬움
- BP만으로도 연결 가능
단점
- 체력 변화가 없어도 매 프레임 검사
- 불필요한 호출이 계속 발생
- 장기적으로는 이벤트 기반 구조보다 비효율적
그래서 GAS를 사용하는 프로젝트라면, 가능하면 Ability System Component에서 Attribute 값 변화 델리게이트를 받아 처리하는 편이 더 좋다.
또 한 가지 주의할 점은, BP의 Event Damage 노드는 GAS 환경에서 기대한 대로 동작하지 않을 수 있다는 점이다.
GAS는 일반적인 TakeDamage() 호출이 아니라 Attribute 값을 직접 변경하는 방식으로 처리되는 경우가 많기 때문이다.
즉, GAS 기반 프로젝트라면 저체력 경고도 Event Damage보다는 Attribute 변경 이벤트 기준으로 연결하는 방식이 더 안정적이다.
이 구조를 적용한 뒤 PIE로 실행해보면,
플레이어 체력이 지정한 임계 구간에 들어갔을 때 화면에 붉은 경고 UI가 나타나고,
반짝이는 애니메이션과 함께 심장박동 소리도 출력되는 것을 확인할 수 있다.
그리고 체력이 다시 기준 이상으로 회복되거나 사망하면,
경고 UI와 사운드가 함께 제거된다.

이번에 구현한 저체력 경고 시스템은 아래 흐름으로 정리할 수 있다.
- 플레이어 체력이 일정 수치 이하로 떨어지면 경고 Widget 생성
- Widget 생성 시 Fade In 애니메이션 재생
- Widget이 유지되는 동안 반복 애니메이션과 심장박동 사운드 재생
- 체력이 회복되거나 사망하면 Fade Out 후 Widget 제거
- 제거 시 사운드도 함께 정리
다만 현재 방식은 빠른 구현을 위한 Tick 기반 임시 구조이므로, 프로젝트를 더 다듬는 단계에서는 GAS Attribute 변화 이벤트를 이용한 방식으로 바꾸면 더 안정적이고 효율적인 구조가 될 것이다.
'Unreal Engine > Functional Implementation' 카테고리의 다른 글
| 언리얼 엔진 파괴 가능한 오브젝트 구현 - 폭발, 파편, 범위 대미지 만들기 (0) | 2025.10.21 |
|---|---|
| 사망 UI 출력 추가 변동 사항 (0) | 2025.09.30 |
| Ultimate Scan Effects and Screen Effects 추가 변동 사항 (0) | 2025.09.29 |
| Teleport 구현 추가 변동 사항 (1) | 2025.09.19 |
| 언리얼 엔진 GAS 텔레포트 구현 - UI 버튼으로 지정 위치 이동하기 (0) | 2025.09.16 |