언리얼 엔진/프로젝트

[언리얼 엔진] 팀 프로젝트 제작 일기 02 - StatComponent로 전투 스탯 관리하기

dhlee-dev 2026. 5. 11. 22:22

어제는 ActorComponent를 상속받는 HealthManager 클래스를 만들어서 체력 관리와 UI 갱신을 분리하는 실습을 해보았다.

이번에는 그 구조를 팀 프로젝트에 맞게 응용해서 StatComponent를 구현했다.

처음에는 체력, 공격력, 방어력, 이동속도, 스태미너를 각각 변수로 관리하는 기본 형태로 작성했다.
이 버전이 처음 커밋했던 StatComponent의 초기 완성 상태였다.

하지만 이후 유물과 강화 시스템을 반영해야 했고, 단순히 스탯 값을 변수로만 관리하는 방식으로는
공격력 증가, 최대 체력 증가, 이동속도 배율 증가 같은 효과를 처리하기 어렵다고 느꼈다.

그래서 이후에는 전투 스탯을 구조체로 분리하고, 기본 스탯과 최종 스탯을 나누어 관리하는 방식으로 확장했다.


StatComponent를 만든 이유

처음에는 어제 작성했던 HealthManager처럼 체력만 별도의 컴포넌트로 관리하면 된다고 생각했다.

하지만 이번 팀 프로젝트에서 전투 시스템을 구현하다 보니 캐릭터가 가져야 하는 값이 체력 하나만 있는 것이 아니었다.

체력
공격력
방어력
이동속도
달리기 속도
스태미너
스태미너 회복량

이런 값들을 캐릭터 클래스 안에 전부 작성하면 캐릭터 클래스가 점점 비대해질 수 있다.

또 플레이어뿐만 아니라 Enemy나 Boss도 체력, 공격력, 방어력 같은 전투 스탯이 필요하다.

그래서 전투와 관련된 스탯을 StatComponent라는 액터 컴포넌트로 분리해서 관리하기로 했다.

PlayerCharacter
 └─ StatComponent

EnemyCharacter
 └─ StatComponent

BossCharacter
 └─ StatComponent

이렇게 하면 플레이어와 적이 같은 스탯 관리 구조를 사용할 수 있고,
각 액터는 델리게이트를 통해 자신에게 필요한 UI, 애니메이션, 이펙트 처리만 따로 연결하면 된다.


초기 완성 상태

처음 작성한 StatComponent는 전투에 필요한 기본 스탯을 각각의 변수로 관리하는 방식이었다.

// 체력
float CurrentHealth;
float MaxHealth;

// 공격력
int32 AttackPower;

// 방어력
int32 Defense;

// 이동속도
float WalkSpeed;
float SprintMultiplier;
float SprintSpeed;

// 스태미너
float CurrentStamina;
float MaxStamina;
float StaminaRegen;

이 구조에서는 체력, 공격력, 방어력, 이동속도, 스태미너를 한 컴포넌트 안에서 관리할 수 있었다.

생성자에서는 기본 스탯 값을 초기화했다.

UStatComponent::UStatComponent()
{
    PrimaryComponentTick.bCanEverTick = false;

    MaxHealth = 100.f;
    AttackPower = 20.f;
    Defense = 5.f;
    WalkSpeed = 600.f;
    SprintMultiplier = 1.5f;
    MaxStamina = 100.f;
    StaminaRegen = 30.f;
    StaminaRegenInterval = 0.1f;
}

PrimaryComponentTick.bCanEverTick = false로 설정해서 매 프레임 Tick은 사용하지 않도록 했다.

현재 구조에서는 매 프레임 스탯을 갱신할 필요가 없고,
데미지를 받거나 스태미너를 사용할 때처럼 필요한 순간에만 값을 변경하면 되기 때문이다.

스태미너 회복도 매 프레임 처리하기보다는, 스태미너를 사용했을 때만 타이머를 실행하는 방식이 더 적절하다고 판단했다.


BeginPlay에서 현재 값 초기화하기

BeginPlay에서는 현재 체력, 달리기 속도, 현재 스태미너를 초기화했다.

void UStatComponent::BeginPlay()
{
    Super::BeginPlay();

    CurrentHealth = MaxHealth;
    SprintSpeed = WalkSpeed * SprintMultiplier;
    CurrentStamina = MaxStamina;

    if (AActor* Owner = GetOwner())
    {
        Owner->OnTakeAnyDamage.AddDynamic(this, &UStatComponent::TakeDamage);
    }
}

현재 체력은 최대 체력으로 시작하고, 현재 스태미너도 최대 스태미너로 시작하도록 했다.

그리고 GetOwner()를 통해 이 컴포넌트를 소유한 Actor를 가져온 뒤,
Owner의 OnTakeAnyDamage 이벤트에 TakeDamage 함수를 바인딩했다.

의미상으로는 다음과 같다.

이 StatComponent를 가진 Actor가 데미지를 받으면
StatComponent의 TakeDamage 함수를 호출한다.

이렇게 하면 StatComponent가 붙어 있는 액터는 플레이어든 Enemy든 같은 방식으로 데미지 처리를 할 수 있다.


초기 버전의 델리게이트

초기 버전에서는 피격, 회복, 사망, 스태미너 소모를 각각 델리게이트로 나누었다.

다만 이때의 OnDamagedOnHealed는 현재 체력과 최대 체력까지 함께 전달했기 때문에,
피격/회복 이벤트 안에 UI 갱신 역할까지 포함된 형태였다.

이후 유물과 강화로 인해 최대 체력 자체가 변경되는 상황을 고려하면서,
피격/회복 이벤트와 체력 UI 갱신 이벤트를 더 명확하게 분리할 필요가 있다고 느꼈다.

DECLARE_DYNAMIC_MULTICAST_DELEGATE_ThreeParams(
    FDamagedSignature,
    float,
    CurrentHealth,
    float,
    MaxHealth,
    float,
    ActualDamage
);

DECLARE_DYNAMIC_MULTICAST_DELEGATE_ThreeParams(
    FHealedSignature,
    float,
    CurrentHealth,
    float,
    MaxHealth,
    float,
    HealAmount
);

DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(
    FDeadSignature,
    AController*,
    Instigator
);

DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(
    FStaminaConsumeSignature,
    float,
    CurrentStamina,
    float,
    MaxStamina
);

각 이벤트의 역할은 다음과 같이 생각했다.

OnDamaged
- 피격 시 호출
- 체력 UI 갱신, 피격 이펙트, 피격 사운드 등에 사용

OnHealed
- 회복 시 호출
- 체력 UI 갱신, 회복 이펙트, 회복 사운드 등에 사용

OnDead
- 사망 시 호출
- 사망 애니메이션, 입력 비활성화, Enemy 제거 등에 사용

OnStaminaConsumed
- 스태미너 소모 시 호출
- 스태미너 UI 갱신에 사용

어제 작성했던 HealthManager에서는 체력 변경 후 UI 갱신 정도만 생각했지만,
실제 프로젝트에서는 피격 효과음, 피격 이펙트, 회복 효과, 사망 처리처럼 상황별로 다른 반응이 필요했다.

그래서 이벤트를 하나로 처리하기보다 용도별로 나누는 것이 더 좋다고 판단했다.


데미지 처리

초기 버전의 데미지 처리는 TakeDamage 함수에서 수행했다.

void UStatComponent::TakeDamage(
    AActor* DamagedActor,
    float Damage,
    const UDamageType* DamageType,
    AController* Instigator,
    AActor* Causer
)
{
    if (IsDead() || Damage <= 0.f) return;

    const float BaseDamage = Damage - Defense;
    const double DamageRange = FMath::RandRange(0.95, 1.05);

    const float ActualDamage = FMath::Max(BaseDamage * DamageRange, 0);

    CurrentHealth -= ActualDamage;
    CurrentHealth = FMath::Max(0.f, CurrentHealth);

    OnDamaged.Broadcast(CurrentHealth, MaxHealth, ActualDamage);

    if (IsDead())
    {
        OnDead.Broadcast(Instigator);
    }
}

먼저 이미 죽었거나 데미지가 0 이하라면 더 이상 처리하지 않는다.

if (IsDead() || Damage <= 0.f) return;

그 다음 받은 데미지에서 방어력을 빼서 기본 데미지를 계산했다.

const float BaseDamage = Damage - Defense;

그리고 데미지에 약간의 랜덤 범위를 적용했다.

const double DamageRange = FMath::RandRange(0.95, 1.05);
const float ActualDamage = FMath::Max(BaseDamage * DamageRange, 0);

FMath::Max를 사용한 이유는 방어력이 데미지보다 높을 경우 실제 데미지가 음수가 되는 것을 막기 위해서이다.

계산된 실제 데미지만큼 현재 체력을 감소시키고, 체력이 0 아래로 내려가지 않도록 처리했다.

CurrentHealth -= ActualDamage;
CurrentHealth = FMath::Max(0.f, CurrentHealth);

그 후 OnDamaged 이벤트를 호출한다.

OnDamaged.Broadcast(CurrentHealth, MaxHealth, ActualDamage);

마지막으로 체력이 0 이하가 되었다면 OnDead 이벤트를 호출한다.

if (IsDead())
{
    OnDead.Broadcast(Instigator);
}

회복 처리

회복은 Heal 함수에서 처리했다.

void UStatComponent::Heal(float Amount)
{
    if (IsDead() || Amount <= 0.f) return;

    const float ActualAmount = (CurrentHealth + Amount > MaxHealth)
        ? MaxHealth - CurrentHealth
        : Amount;

    CurrentHealth += ActualAmount;

    OnHealed.Broadcast(CurrentHealth, MaxHealth, ActualAmount);
}

이미 죽은 상태이거나 회복량이 0 이하라면 처리하지 않는다.

그리고 회복 후 체력이 최대 체력을 넘지 않도록 실제 회복량을 계산했다.

const float ActualAmount = (CurrentHealth + Amount > MaxHealth)
    ? MaxHealth - CurrentHealth
    : Amount;

이렇게 하면 현재 체력이 90이고 최대 체력이 100인 상태에서 30을 회복하더라도 실제 회복량은 10으로 처리된다.

그 후 현재 체력을 증가시키고 OnHealed 이벤트를 호출한다.

CurrentHealth += ActualAmount;

OnHealed.Broadcast(CurrentHealth, MaxHealth, ActualAmount);

이 이벤트를 통해 UI 갱신, 회복 이펙트, 회복 사운드 같은 처리를 연결할 수 있다.


이동속도 처리

이동속도는 기본 이동속도와 달리기 속도를 구분해서 관리했다.

기본 이동속도는 WalkSpeed이고, 달리기 속도는 WalkSpeed * SprintMultiplier로 계산한다.

float UStatComponent::GetMovementSpeed(bool bIsSprint) const
{
    return bIsSprint ? SprintSpeed : WalkSpeed;
}

캐릭터 쪽에서는 달리기 상태인지에 따라 GetMovementSpeed를 호출해서 이동속도를 가져오면 된다.

이동속도가 변경될 경우에는 ChangeSpeed 함수를 호출한다.

void UStatComponent::ChangeSpeed(float Speed)
{
    WalkSpeed = Speed;
    SprintSpeed = WalkSpeed * SprintMultiplier;
}

이렇게 하면 기본 이동속도가 바뀌었을 때 달리기 속도도 함께 다시 계산된다.


스태미너 처리

스태미너를 소모하는 함수는 TryConsumeStamina이다.

bool UStatComponent::TryConsumeStamina(float Cost)
{
    if (!bUseStamina) return true;

    UWorld* World = GetWorld();
    if (!World) return false;

    if (CurrentStamina < Cost) return false;

    CurrentStamina -= Cost;
    OnStaminaConsumed.Broadcast(CurrentStamina, MaxStamina);

    if (!World->GetTimerManager().IsTimerActive(StaminaRegenTimerHandle))
    {
        World->GetTimerManager().SetTimer(
            StaminaRegenTimerHandle,
            this,
            &UStatComponent::RegenerateStamina,
            StaminaRegenInterval,
            true
        );
    }

    return true;
}

먼저 bUseStamina가 false라면 스태미너를 사용하지 않는 액터이므로 바로 true를 반환한다.

if (!bUseStamina) return true;

이렇게 하면 Enemy처럼 스태미너가 필요 없는 액터도 같은 StatComponent를 사용할 수 있다.

스태미너를 사용하는 액터라면 현재 스태미너가 비용보다 적은지 확인한다.

if (CurrentStamina < Cost) return false;

스태미너가 충분하다면 비용만큼 감소시키고 OnStaminaConsumed 이벤트를 호출한다.

CurrentStamina -= Cost;
OnStaminaConsumed.Broadcast(CurrentStamina, MaxStamina);

그 후 스태미너 회복 타이머가 켜져 있지 않다면 타이머를 시작한다.

World->GetTimerManager().SetTimer(
    StaminaRegenTimerHandle,
    this,
    &UStatComponent::RegenerateStamina,
    StaminaRegenInterval,
    true
);

이 함수는 성공 여부를 bool로 반환하기 때문에, 캐릭터 쪽에서는 다음처럼 사용할 수 있다.

if (StatComponent->TryConsumeStamina(DodgeCost))
{
    // 회피 실행
}

스태미너가 부족하면 false가 반환되므로 행동을 막을 수 있다.


스태미너 회복

스태미너 회복은 타이머를 사용해서 일정 간격마다 실행되도록 했다.

void UStatComponent::RegenerateStamina()
{
    CurrentStamina += StaminaRegen * StaminaRegenInterval;
    CurrentStamina = FMath::Min(CurrentStamina, MaxStamina);

    if (CurrentStamina >= MaxStamina)
    {
        UWorld* World = GetWorld();
        if (!World) return;

        World->GetTimerManager().ClearTimer(StaminaRegenTimerHandle);
    }
}

현재 스태미너에 초당 회복량과 회복 간격을 곱한 값을 더한다.

CurrentStamina += StaminaRegen * StaminaRegenInterval;

그리고 최대 스태미너를 넘지 않도록 FMath::Min을 사용했다.

CurrentStamina = FMath::Min(CurrentStamina, MaxStamina);

스태미너가 가득 차면 더 이상 회복할 필요가 없기 때문에 타이머를 종료한다.

World->GetTimerManager().ClearTimer(StaminaRegenTimerHandle);

이렇게 해서 초기 버전의 StatComponent는 체력, 공격력, 방어력, 이동속도, 스태미너를 관리하고,
데미지, 회복, 사망, 스태미너 소모 이벤트를 발생시키는 기본 구조를 갖추게 되었다.


초기 구조의 한계

초기 버전만으로도 기본 전투 스탯 관리는 가능했다.

하지만 유물과 강화 시스템을 생각하면서 구조를 다시 고민하게 되었다.

예를 들어 유물이나 강화 효과로 다음과 같은 스탯 변화가 생길 수 있다.

공격력 +10
공격력 +10%
방어력 +5
최대 체력 +30
이동속도 +10%
스태미너 회복량 증가

이런 효과를 각각의 변수에 직접 더하고 빼는 방식으로 처리하면,
나중에 유물을 제거하거나 여러 보너스가 겹쳤을 때 관리가 어려워질 수 있다.

또 기본 스탯과 보정이 적용된 최종 스탯을 구분하지 않으면,
현재 값이 원래 캐릭터의 스탯인지 유물 효과가 반영된 값인지 알기 어려워진다.

그래서 이후에는 기본 스탯과 최종 스탯을 분리하고, 유물이나 강화 효과는 Modifier로 관리하는 구조로 확장했다.


CombatStat 구조체 추가

확장 버전에서는 전투 스탯 관련 구조를 CombatStat.h로 분리했다.

먼저 스탯 종류를 enum으로 관리했다.

현재 관리하는 스탯은 다음과 같다.

MaxHealth        최대 체력
AttackPower      공격력
Defense          방어력
WalkSpeed        기본 이동 속도
SprintMultiplier 달리기 속도 배율
MaxStamina       최대 스태미너
StaminaRegen     스태미너 회복량

처음에는 이 값들을 각각 변수로 관리했지만,
유물이나 강화가 특정 스탯을 공통된 방식으로 수정하려면 “어떤 스탯을 변경할 것인지”를 값으로 표현할 수 있어야 했다.

그래서 스탯 종류를 enum으로 관리하도록 변경했다.


FCombatStat 구조체

실제 스탯 값은 FCombatStat 구조체에서 관리하도록 했다.


위 구조체에서는 생성자와 초기화 함수를 통해 enum 개수에 맞게 배열 크기를 맞추었다.

그리고 연산자 오버로딩과 Getter/Setter 함수를 추가해서 스탯 값을 공통된 방식으로 계산하고 접근할 수 있도록 했다.

스탯 값은 TArray<float>로 저장하고, ECombatStatType의 enum 값을 인덱스처럼 사용한다.

예를 들어 최대 체력 값을 가져올 때는 다음과 같이 사용할 수 있다.

float MaxHealth = Stats.GetValue(ECombatStatType::MaxHealth);

스탯 값을 변경할 때도 같은 방식으로 처리한다.

Stats.SetValue(ECombatStatType::AttackPower, 30.f);
Stats.AddValue(ECombatStatType::Defense, 5.f);

이렇게 하면 새로운 스탯이 추가되더라도 enum에 타입을 추가하고,
해당 값만 관리하면 되기 때문에 확장하기가 더 쉬워진다.


BaseStats와 FinalStats 분리

확장 버전의 StatComponent에서는 스탯을 두 가지로 나누었다.

BaseStats는 캐릭터가 원래 가지고 있는 기본 스탯이다.
FinalStats는 유물이나 강화 효과까지 모두 반영된 최종 스탯이다.

BaseStats
- 캐릭터의 기본 스탯

FinalStats
- BaseStats에 유물/강화 보정이 적용된 최종 스탯

예를 들어 기본 공격력이 20이고, 유물로 공격력 +10을 받았다면
BaseStats는 그대로 20을 유지하고, FinalStats에서 30으로 계산되는 구조가 된다.

이렇게 나누면 기본값과 보정값을 구분할 수 있어서 관리가 훨씬 쉬워진다.


Modifier 구조 추가

유물이나 강화로 인해 변경되는 스탯은 FStatModifier 구조체로 관리했다.

Modifier에는 어떤 유물이나 강화에서 온 효과인지 구분하기 위한 SourceID를 넣었다.

그리고 어떤 스탯을 변경할지 나타내는 StatType, 더할지 곱할지 나타내는 ModifierType, 실제 수치인 Value를 가지고 있다.

SourceID
- 어떤 유물/강화에서 온 효과인지 구분

StatType
- 변경할 스탯 종류

ModifierType
- Add 또는 Multiply

Value
- 변경할 수치

예를 들어 공격력 +10 효과는 다음과 같이 표현할 수 있다.

SourceID: Relic_Attack_01
StatType: AttackPower
ModifierType: Add
Value: 10

공격력 20% 증가 효과라면 곱 연산용 Modifier로 표현할 수 있다.

현재 구조에서는 MultiplyStats의 기본값을 1.0으로 두고, Multiply Modifier의 값을 더한 뒤 최종 스탯 계산에 사용한다.
따라서 공격력 20% 증가 효과는 Value0.2를 넣는 방식으로 표현할 수 있다.

SourceID: Relic_AttackPercent_01
StatType: AttackPower
ModifierType: Multiply
Value: 0.2

// 기본 배율 1.0 + 0.2 = 1.2배

이런 식으로 관리하면 유물, 장비, 강화, 버프 등으로 인한 스탯 변화를 하나의 구조로 처리할 수 있다.


Modifier 추가와 제거

확장 버전의 StatComponent에서는 적용 중인 Modifier들을 배열로 관리한다.

UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Stat")
TArray<FStatModifier> Modifiers;

Modifier가 추가되거나 제거되면 최종 스탯을 다시 계산한다.

이 구조를 사용하면 유물을 획득했을 때는 AddModifier를 호출하고,
유물이 제거되거나 효과가 끝났을 때는 RemoveModifier를 호출하면 된다.

SourceID를 기준으로 제거할 수 있기 때문에 특정 유물에서 온 효과만 제거하는 것도 가능하다.


FinalStats 계산

최종 스탯은 CalculateFinalStats 함수에서 계산한다.

계산 방식은 크게 두 단계로 나누었다.

1. Add 타입 Modifier를 모두 더한다.
2. Multiply 타입 Modifier를 곱 연산용 Modifier에 반영한다.

최종적으로는 다음과 같은 형태가 된다.

FinalStats = (BaseStats + AddStats) * MultiplyStats

처음에는 단순히 스탯마다 변수를 두었지만,
이렇게 구조체와 Modifier를 사용하니 유물이나 강화 효과를 더 일반적으로 처리할 수 있게 되었다.

최대 체력 변경 처리

Modifier를 적용하다 보니 최대 체력이나 최대 스태미너가 변경되는 문제도 고려해야 했다.

예를 들어 현재 체력이 최대 체력과 같은 상태에서 최대 체력 증가 유물을 얻었다면,
최대 체력만 늘어나고 현재 체력은 그대로 둘지, 현재 체력도 같이 늘릴지 결정해야 했다.

확장 버전에서는 기존에 체력이 가득 찬 상태였다면 현재 체력도 새로운 최대 체력을 따라가도록 처리했다.

bool bIsMaxHealth = FMath::IsNearlyEqual(CurrentHealth, GetMaxHealth());
bool bIsMaxStamina = FMath::IsNearlyEqual(CurrentStamina, GetMaxStamina());

// ...
// 보너스 스탯 계산 코드 생략

FinalStats = (BaseStats + AddStats) * MultiplyStats;

CurrentHealth = bIsMaxHealth
    ? GetMaxHealth()
    : FMath::Clamp(CurrentHealth, 0, GetMaxHealth());

CurrentStamina = bIsMaxStamina
    ? GetMaxStamina()
    : FMath::Clamp(CurrentStamina, 0, GetMaxStamina());

즉, 체력이 가득 찬 상태에서 최대 체력이 늘어나면 현재 체력도 새로운 최대 체력으로 맞춘다.

반대로 체력이 이미 줄어든 상태라면 현재 체력을 그대로 유지하되, 최대 체력보다 커지지 않도록 Clamp 처리한다.

스태미너도 같은 방식으로 처리했다.

이런 처리를 넣지 않으면 최대 체력이나 최대 스태미너가 변경되었을 때 UI와 실제 값이 어색하게 보일 수 있다.

최종 스탯을 다시 계산한 뒤에는 체력이나 스태미너의 최대값이 달라졌을 수 있으므로 OnHealthChangedOnStaminaChanged를 호출했다.
이렇게 하면 유물이나 강화로 최대 체력, 최대 스태미너가 변경되었을 때도 UI가 바로 갱신될 수 있다.


델리게이트도 다시 분리하기

초기 버전에서는 OnDamaged 이벤트에 현재 체력, 최대 체력, 실제 데미지를 함께 넘겼다.

이 방식도 사용할 수는 있지만, 유물이나 강화로 최대 체력이 변경되는 상황까지 생각하면 문제가 생긴다.

예를 들어 최대 체력 증가 유물을 얻으면 체력 UI는 갱신되어야 한다.

하지만 이 상황은 피격이 아니다.

그런데 UI 갱신을 OnDamaged에만 묶어두면, 유물로 인해 체력이 변경되는 경우와 실제 피격을 구분하기 어려워질 수 있다.

그래서 확장 버전에서는 이벤트를 조금 더 명확하게 나누었다.

각 이벤트의 역할은 다음과 같다.

OnDamaged
- 실제 피격이 발생했을 때 호출
- 피격 사운드, 피격 이펙트 등에 사용

OnHealed
- 회복이 발생했을 때 호출
- 회복 사운드, 회복 이펙트 등에 사용

OnHealthChanged
- 현재 체력 또는 최대 체력이 변경되었을 때 호출
- 체력 UI 갱신에 사용

OnDead
- 사망했을 때 호출
- 사망 처리에 사용

OnStaminaChanged
- 현재 스태미너 또는 최대 스태미너가 변경되었을 때 호출
- 스태미너 UI 갱신에 사용

이렇게 나누면 같은 체력 변화라도 원인에 따라 다른 처리를 할 수 있다.

즉, OnDamaged는 “피격이라는 사건”을 알리는 이벤트이고,
OnHealthChanged는 “체력 값이 변경되었다”는 상태 변화를 알리는 이벤트로 나누었다.

피격으로 체력이 줄어들면 OnDamagedOnHealthChanged를 함께 호출하고,
유물로 최대 체력이 바뀌는 경우에는 OnHealthChanged만 호출하면 된다.


확장 버전의 데미지 처리

확장 버전에서는 데미지 처리 후 OnDamagedOnHealthChanged를 따로 호출하도록 했다.

초기 버전에서는 OnDamaged가 UI 갱신에 필요한 현재 체력과 최대 체력까지 함께 전달했다.

하지만 확장 버전에서는 역할을 분리했다.

OnDamaged
- 실제 받은 데미지 전달
- 피격 연출 처리

OnHealthChanged
- 현재 체력과 최대 체력 전달
- UI 갱신 처리

이렇게 하면 피격 연출과 UI 갱신을 분리해서 바인딩할 수 있다.


확장 버전의 회복 처리

회복 처리도 마찬가지로 OnHealedOnHealthChanged를 나누었다.

회복이 발생했으므로 OnHealed를 호출하고, 체력 값도 변경되었으므로 OnHealthChanged를 호출한다.

OnHealed
- 실제 회복량 전달
- 회복 이펙트, 회복 사운드 처리

OnHealthChanged
- 현재 체력과 최대 체력 전달
- 체력 UI 갱신

이렇게 하면 회복 연출과 UI 갱신도 분리해서 처리할 수 있다.


플레이어와 Enemy에 함께 사용하기

StatComponent는 플레이어 캐릭터뿐만 아니라 Enemy에도 붙일 수 있도록 만들었다.

체력, 공격력, 방어력 같은 기본 전투 스탯은 플레이어와 적 모두에게 필요하기 때문이다.

PlayerCharacter
 └─ StatComponent

EnemyCharacter
 └─ StatComponent

BossCharacter
 └─ StatComponent

플레이어는 스태미너를 사용할 수 있으므로 bUseStamina를 true로 두고, 적은 스태미너를 사용하지 않는다면 false로 둘 수 있다.

또 Enemy에게 강화가 필요하다면 Modifier를 추가해서 처리할 수 있다.

예를 들어 특정 웨이브 이후 적의 공격력을 올리거나, 보스에게 방어력 증가 효과를 주는 것도 같은 구조로 처리할 수 있다.

Enemy 강화 예시

AttackPower + 10
Defense + 5
MaxHealth * 1.2

이런 식으로 구현하면 플레이어와 적이 같은 스탯 시스템을 사용하면서도, 각자 다른 방식으로 확장할 수 있다.


구현하면서 느낀 점

처음에는 단순히 체력, 공격력, 방어력, 이동속도, 스태미너를 각각 변수로 두고 관리했다.

이 방식은 초기 구현에는 단순하고 이해하기 쉬웠다.

하지만 유물과 강화 시스템을 생각하니 단순 변수 방식은 점점 복잡해질 것 같았다.

특히 공격력 +10 같은 단순 증가뿐만 아니라 공격력 10% 증가처럼 곱 연산 보정도 필요할 수 있었기 때문에,
스탯 변경을 Modifier 구조로 관리하는 편이 더 적절하다고 생각했다.

또 실제 프로젝트에서는 체력 변경의 원인도 구분해야 했다.

데미지를 받아서 체력이 줄어든 경우에는 피격 사운드나 이펙트가 필요하지만,
유물 효과로 최대 체력이 변경된 경우에는 UI만 갱신하면 된다.

그래서 OnDamaged 하나에 모든 처리를 묶기보다,
OnDamaged, OnHealed, OnHealthChanged, OnDead, OnStaminaChanged처럼 이벤트를 나누는 것이 더 좋다고 판단했다.


정리

이번에는 어제 작성했던 HealthManager 구조를 응용해서 팀 프로젝트용 StatComponent를 구현했다.

처음 커밋한 초기 버전에서는 전투에 필요한 기본 스탯을 각각 변수로 관리했다.

초기 StatComponent
- 체력 관리
- 공격력 관리
- 방어력 관리
- 이동속도 관리
- 스태미너 관리
- 데미지 처리
- 회복 처리
- 사망 이벤트 발생
- 스태미너 소모 처리

이후 유물과 강화 시스템을 고려하면서 구조를 확장했다.

확장된 StatComponent
- ECombatStatType으로 스탯 종류 관리
- FCombatStat으로 스탯 값 관리
- BaseStats와 FinalStats 분리
- FStatModifier로 유물/강화 효과 관리
- Add / Multiply 방식의 보정 처리
- OnHealthChanged, OnStaminaChanged로 UI 갱신 이벤트 분리

처음부터 복잡한 구조를 만들 수도 있었지만,
먼저 단순한 형태로 동작하는 StatComponent를 만들고, 이후 실제 필요한 기능이 생기면서 구조를 확장했다.

이번 작업을 통해 ActorComponent를 단순한 체력 관리뿐만 아니라, 전투 시스템의 공통 스탯 관리에도 활용할 수 있다는 것을 알 수 있었다.

앞으로 유물, 강화, 버프, Enemy 강화 같은 기능도 이 Modifier 구조를 활용해서 확장해볼 수 있을 것 같다.