구현내용
공격이 적중했을때 잠시 게임의 시간을 멈춰 타격감을 극대화 하는 연출인 히트스탑
공격이 적중했을때 적중한곳에서 데미지 숫자를 시각적으로 보여주는 Hud를 구현하였습니다
구현목적
AI와의 전투시스템을 구현하기전에 전투 게임에서 타격감은 중요시되기에
그중에 한개인 HitStop시스템을 넣기로 하였습니다
또한 스탯시스템이 존재하기에 시각적으로 보여주기위해 허드시스템이 필요하였습니다
HitStop 구현에 사용한클래스
| 사용한 클래스 | 설명 |
| HitInterface(Interface) | 적이 Hit 이벤트를 받는 인터페이스 |
| GameManager(Actor) | 게임의 전역 매니저 역할을 하는 매니저 히트스탑 요청을 받아 실제로 적용해주는 수신탑 |
HitStop 구현
HitInterface구현은 이와같습니다
2025.08.23 - [Unreal 프로젝트 다이어리/두번째 프로젝트] - Unreal - HitInterface 상호작용
Unreal - HitInterface 상호작용
적이 Override하여 사용할 HitInterface를 만들어줍니다#pragma once#include "CoreMinimal.h"#include "UObject/Interface.h"#include "HitInterface.generated.h"USTRUCT(BlueprintType)struct FGameHitSystemData{ GENERATED_BODY() //공격 데미지 UPRO
lucodev.tistory.com
일단 HitStop이란? 피격자 혹은 공격자&피격자 가 공격이 적중했을때 시간이 잠깐 느려지는 전투 시스템중 한개입니다.
언리얼에서는 SetGlobalTimeDilation으로 월드의 시간을 변경 / 지연시켜서 쉽게 구현이 가능합니다
일단 히트스탑은 어디에 구현되어야할까?
코드를 짜기전에 많은 생각을 했다 히트스탑 기능은 어디에있어야할까?
공격하는 플레이어? 공격을받는 AI? 혹은 게임인스턴스? 게임모드? 많은 생각을 하다가
게임매니저 라는 액터를 월드에 한개 배치하여 게임 매니저 역할을 하는 게임매니저를 생성하였다
앞서 말했듯이 CustomTimeDilation을 조절하여 쉽게 구현이 가능하다
게임매니저에 히트스탑을 구현하였다
void AGameManager::PlayHitStop(AActor* targetActor, float duration, float dilation)
{
if (!IsValid(targetActor) || duration <= 0.f)
return;
targetActor->CustomTimeDilation = dilation;
FTimerHandle th_HitStopTimer;
FTimerDelegate td_HitStopDelegate;
td_HitStopDelegate.BindUObject(this, &AGameManager::EndHitStop, targetActor);
if (GetWorld())
GetWorld()->GetTimerManager().SetTimer(th_HitStopTimer, td_HitStopDelegate, duration, false);
}
void AGameManager::EndHitStop(AActor* targetActor)
{
if (IsValid(targetActor))
targetActor->CustomTimeDilation = 1.0f;
}
또한 게임매니저 내부에 싱글톤 정적 헬퍼 함수를 만들어주면 콜하기 쉬워집니다
static AGameManager* GetManagerInstance(UWorld* world);
AGameManager* AGameManager::GetManagerInstance(UWorld* world)
{
if (!world)
return nullptr;
for (TActorIterator<AGameManager> it(world); it; ++it)
{
AGameManager* manager = *it;
if (IsValid(manager))
return manager;
}
return nullptr;
}
호출하는방식은 이와같습니다
월드상에서 GameManager 인스턴스를 찾아 gameManager 포인터에 저장합니다 필자는 Beginplay에서 먼저 저장하였습니다
gameManager = AGameManager::GetManagerInstance(GetWorld());
수신탑인 게임매니저에 PlayHitStop을 요청합니다 hitActor는 맞는대상, mainChar는 공격자 인 캐릭터입니다
if (gameManager)
{
//0.05 , 0.1
float hitStopDuration = 0.055f;
float hitStopDilation = 0.1f;
gameManager->PlayHitStop(hitActor, hitStopDuration, hitStopDilation);
gameManager->PlayHitStop(mainChar, hitStopDuration, hitStopDilation);
}
HitStop 결과물
플레이어의 플레이를 해치지않을정도로 아주 약간의 타임딜레이션만 손봤기때문에 티가 잘 안날수도 있지만
차이는 극심하다 무언가를 벤다 라는 느낌을 준다

히트스탑을 적용하기 전
히트스탑을 적용한 후
Hud 구현
플레이어가 적을 때린 위치에서 플레이어의 현재 공격력에 기반한 최종데미지수치가 반영돈 텍스트가
나오도록 구현하였습니다
한 개의 위젯만 만들어 애니메이션을 재생하도록 구현하게된다면 동시에 여러 텍스트를 표기할수 없기에
위젯 하나를 기준으로 해당 위젯의 애니메이션을 실행하는 팝업 액터를 스폰 하는 방식으로 제작하였습니다
또한 매번 액터를 스폰하면 최적화 측면에서 효율적이지 않기 때문에 스트리밍 풀 방식을 사용했습니다.
먼저 위젯을 만들고 애니메이션을 제작해주었습니다
위젯은 간단하게 구현되어있습니다 크기를 조절하기쉽게 스케일박스 내부에 만들어주었습니다

애니메이션을 바인드하고 애니메이션이 끝났을때 브로드캐스트하여 끝난 이벤트를 바인드해주었습니다
#include "DamageHudWidget.h"
#include "Components/Image.h"
#include "Animation/WidgetAnimation.h"
#include "Components/TextBlock.h"
void UDamageHudWidget::NativeConstruct()
{
Super::NativeConstruct();
Image_Fire->SetVisibility(ESlateVisibility::Collapsed);
if (Normal)
{
FWidgetAnimationDynamicEvent dEvent;
dEvent.BindDynamic(this, &UDamageHudWidget::OnAnimFinished);
BindToAnimationFinished(Normal, dEvent);
}
if (Critical)
{
FWidgetAnimationDynamicEvent dEvent;
dEvent.BindDynamic(this, &UDamageHudWidget::OnAnimFinished);
BindToAnimationFinished(Critical, dEvent);
}
if (FireDot)
{
FWidgetAnimationDynamicEvent dEvent;
dEvent.BindDynamic(this, &UDamageHudWidget::OnAnimFinished);
BindToAnimationFinished(FireDot, dEvent);
}
if (FireDot2)
{
FWidgetAnimationDynamicEvent dEvent;
dEvent.BindDynamic(this, &UDamageHudWidget::OnAnimFinished);
BindToAnimationFinished(FireDot2, dEvent);
}
}
void UDamageHudWidget::InitDamage(int32 damage, EDamageType type)
{
TextBlock_DamageText->SetText(FText::AsNumber(damage));
Image_Fire->SetVisibility(ESlateVisibility::Collapsed);
switch (type)
{
case EDamageType::Normal:
PlayAnimation(Normal);
break;
case EDamageType::Critical:
PlayAnimation(Critical);
break;
case EDamageType::FireDot:
{
Image_Fire->SetVisibility(ESlateVisibility::Visible);
int32 randomIdx = FMath::RandRange(1, 2);
if (randomIdx == 1)
PlayAnimation(FireDot);
else
PlayAnimation(FireDot2);
break;
}
case EDamageType::None:
default:
break;
}
}
void UDamageHudWidget::OnAnimFinished()
{
onDamageWidgetFinished.Broadcast();
}
이제 스폰되는 팝업액터를 만들어주었습니다
UCLASS()
class PORTFOLIOMS_API ADamagePopup : public AActor
{
GENERATED_BODY()
public:
ADamagePopup();
protected:
virtual void BeginPlay() override;
public:
UFUNCTION()
void ShowHud(FVector& worldLoc, int32 Damage, EDamageType type);
UFUNCTION()
void OnWidgetFinished();
public:
UPROPERTY(EditAnywhere, Category="UI")
TSubclassOf<class UDamageHudWidget> damageWidgetClass;
UPROPERTY()
class UDamageHudWidget* damagewidget;
UPROPERTY()
UWidgetComponent* damageWidgetComp;
UPROPERTY()
class AMainCharacterController* mainCon;
UPROPERTY()
bool bIsInUse = false;
TFunction<void()> onWidgetFinishedDelegate;
};
위젯 컴포넌트를붙혀서 데미지 Hud위젯을 표시하도록 구성하였으며
ShowHud함수 호추시 액터 위치를 설정하고 위젯을 초기화 한뒤 화면에 표시합니다
위젯 애니메이션 종료 시 Delegate를 통해 사용 종료를 처리하고 위젯을 다시 숨깁니다
ADamagePopup::ADamagePopup()
{
PrimaryActorTick.bCanEverTick = false;
RootComponent = CreateDefaultSubobject<USceneComponent>(TEXT("Root"));
damageWidgetComp = CreateDefaultSubobject<UWidgetComponent>(TEXT("DamageWidgetComp"));
damageWidgetComp->SetupAttachment(RootComponent);
damageWidgetComp->SetWidgetSpace(EWidgetSpace::Screen);
damageWidgetComp->SetDrawSize(FVector2D(500.f, 100.f));
damageWidgetComp->SetPivot(FVector2D(0.5f, 0.5f));
}
void ADamagePopup::BeginPlay()
{
Super::BeginPlay();
mainCon = Cast<AMainCharacterController>(UGameplayStatics::GetPlayerController(GetWorld(), 0));
if (!mainCon || !damageWidgetClass)
return;
damageWidgetComp->SetWidgetClass(damageWidgetClass);
damagewidget = Cast<UDamageHudWidget>(damageWidgetComp->GetUserWidgetObject());
damagewidget->onDamageWidgetFinished.AddDynamic(this, &ADamagePopup::OnWidgetFinished);
damageWidgetComp->SetVisibility(false);
}
void ADamagePopup::ShowHud(FVector& worldLoc, int32 Damage, EDamageType type)
{
if (!damageWidgetComp || !damagewidget)
return;
SetActorLocation(worldLoc);
damagewidget->InitDamage(Damage, type);
damageWidgetComp->SetVisibility(true);
bIsInUse = true;
onWidgetFinishedDelegate = [this]()
{
bIsInUse = false;
damageWidgetComp->SetVisibility(false);
};
}
void ADamagePopup::OnWidgetFinished()
{
if (onWidgetFinishedDelegate)
{
onWidgetFinishedDelegate();
onWidgetFinishedDelegate = nullptr;
}
}
이제 게임매니저에 부착할 팝업액터의 스트리밍 풀 시스템을 구현하였습니다
maxPoolSize만큼 ADamagePopup액터를 미리 스폰한뒤 allPopups배열에 저장한뒤
재사용하여 사용하였습니다
maxPoolSize가 만약 10 이라면 1 2 3 4 5 6 7 8 9 10 - 1 2 3 4 5 6 7.. 이렇게 갯수 내부에서 재활용하여 사용됩니다
#include "DamagePopupPool.h"
#include "Kismet/GameplayStatics.h"
#include "DamagePopup.h"
ADamagePopupPool::ADamagePopupPool()
{
PrimaryActorTick.bCanEverTick = true;
}
void ADamagePopupPool::BeginPlay()
{
Super::BeginPlay();
if (!damagePopupClass)
return;
UWorld* world = GetWorld();
if (!world)
return;
for (int32 i = 0; i < maxPoolSize; ++i)
{
ADamagePopup* spawnNewPopup = world->SpawnActor<ADamagePopup>(damagePopupClass);
if (spawnNewPopup)
{
spawnNewPopup->bIsInUse = false;
spawnNewPopup->onWidgetFinishedDelegate = nullptr;
allPopups.Add(spawnNewPopup);
}
}
}
void ADamagePopupPool::ActiveDamageHud(FVector& worldLoc, int32 damage, EDamageType type)
{
if (allPopups.Num() == 0)
return;
int32 startIndex = currentIndex;
ADamagePopup* popup = nullptr;
do
{
popup = allPopups[currentIndex];
currentIndex = (currentIndex + 1) % allPopups.Num();
} while (popup && popup->bIsInUse && currentIndex != startIndex);
if (popup && !popup->bIsInUse)
popup->ShowHud(worldLoc, damage, type);
}
이제 마지막으로 게임에 배치된 GameManager 액터에 붙혀주고 활성화시켜주었습니다
public:
UPROPERTY(EditAnywhere, Category = "Pool")
TSubclassOf<ADamagePopupPool> damagePopupPoolClass;
UPROPERTY()
ADamagePopupPool* damagePopupPool;
UFUNCTION()
void ActiveDamagePopupPool();
void AGameManager::ActiveDamagePopupPool()
{
if (damagePopupPoolClass && world)
{
FActorSpawnParameters poolSpawnParams;
poolSpawnParams.Owner = this;
poolSpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
damagePopupPool = world->SpawnActor<ADamagePopupPool>(damagePopupPoolClass, FVector::ZeroVector, FRotator::ZeroRotator, poolSpawnParams);
}
}
해당 함수는 게임매니저의 Beginplay에서 한번만 실행
HitInterface입니다
USTRUCT(BlueprintType)
struct FGameHitSystemData
{
GENERATED_BODY()
//공격 데미지
UPROPERTY(BlueprintReadWrite)
float damageAmount;
//공격 방향 (-180 ~ 180)
UPROPERTY(BlueprintReadWrite)
float hitDirection;
//패링가능 여부
UPROPERTY(BlueprintReadWrite)
bool bCanParry;
//넉백강도
UPROPERTY(BlueprintReadWrite)
float knockBackStrength;
//맞은곳
UPROPERTY(BlueprintReadWrite)
FVector hitLocation;
UPROPERTY(BlueprintReadWrite)
EDamageType damageType;
};
적이 데미지를 받을때 이와같이 호출합니다
데미지를 받은 위치, 에서 데이터의 데미지 양 만큼 타입은 Normal로
(현재는 크리티컬이나 다른 데미지가 구현되어있지않기때문에 Normal로 실행하였습니다)
gameManager->damagePopupPool->ActiveDamageHud(hitLoc, damageData.damageAmount, EDamageType::Normal);
총 결과물

'Unreal 프로젝트 다이어리 > 두번째 프로젝트' 카테고리의 다른 글
| Unreal - Blood Decal (피 튀기기) (0) | 2025.12.10 |
|---|---|
| Unreal - 스킬 트리 (0) | 2025.12.10 |
| Unreal - 스탯창 (0) | 2025.12.02 |
| Unreal - 세이브 게임 (0) | 2025.12.02 |
| Unreal - 경험치, 레벨업 구현하기 (0) | 2025.12.02 |