카테고리 없음

Unreal C++ TIL — 지뢰 피하기 게임 3-4📚 — 캐릭터 체력 시스템 & 아이템 상호작용

view29579 2026. 8. 24. 20:54

1. 캐릭터 체력 시스템

이번에는 캐릭터에게 최대 체력과 현재 체력을 만들어주었다.

Header

 
// 최대 체력
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Health")
float MaxHealth;

// 현재 체력
UPROPERTY(VisibleAnywhere, BlueprintReadWrite, Category = "Health")
float Health;
 

내가 이해한 것

 
MaxHealth
 

→ 캐릭터가 가질 수 있는 최대 체력

 
Health
 

→ 현재 캐릭터가 가지고 있는 실제 체력

예를 들어:

MaxHealth = 100
Health = 100
 

상태에서 데미지를 30 받으면:

MaxHealth = 100
Health = 70
 

이 된다.


2. 생성자에서 체력 초기화

 
MaxHealth = 100.0f;
Health = MaxHealth;
 

처음에는 최대 체력을 100으로 설정하고,

 
Health = MaxHealth;
 

현재 체력을 최대 체력과 동일하게 만들어준다.

즉:

게임 시작
 ↓
MaxHealth = 100
 ↓
Health = MaxHealth
 ↓
Health = 100
 

왜 Health = 100.0f라고 직접 쓰지 않고 MaxHealth를 넣었는가?

나중에 최대 체력을 변경하더라도 현재 체력을 자동으로 최대 체력에 맞출 수 있기 때문이다.

 
MaxHealth = 150.0f;
Health = MaxHealth;
 

로 바꾸면 현재 체력도 150이 된다.


3. 체력 조회 함수

 
UFUNCTION(BlueprintPure, Category = "Health")
int32 GetHealth() const;
 

현재 체력을 가져오는 함수이다.

CPP:

 
float AJunCharacter::GetHealth() const
{
    return Health;
}
 

여기서 조금 특이했던 점은 Header에서는:

 
int32 GetHealth() const;
 

로 되어 있었는데 실제 Health는:

 
float Health;
 

이다.

따라서 현재 코드에서는 반환형을 float로 맞추는 것이 더 자연스럽다.

 
float GetHealth() const;
 

그리고 CPP도:

 
float AJunCharacter::GetHealth() const
{
    return Health;
}
 

로 맞추는 게 좋다.

const를 붙이는 이유

이 함수는 현재 체력을 가져오기만 하고 캐릭터의 값을 변경하지 않기 때문이다.

 
GetHealth()
 

→ 읽기만 함

그래서:

 
const
 

를 붙인다.


4. 체력 회복 함수

 
UFUNCTION(BlueprintCallable, Category = "Health")
void AddHealth(float Amount);
 

CPP:

 
void AJunCharacter::AddHealth(float Amount)
{
    Health = FMath::Clamp(
        Health + Amount,
        0.0f,
        MaxHealth
    );

    UE_LOG(
        LogTemp,
        Log,
        TEXT("Health increased to: %f"),
        Health
    );
}
 

FMath::Clamp()가 핵심

 
FMath::Clamp(Health + Amount, 0.0f, MaxHealth);
 

의 의미는:

Health + Amount
       ↓
0보다 작으면 → 0
MaxHealth보다 크면 → MaxHealth
그 사이면 → 계산된 값
 

예를 들어 현재 체력이:

Health = 70
 

이고 회복량이:

Amount = 20
 

이면:

70 + 20 = 90
 

→ Health = 90

하지만:

Health = 90
Amount = 30
 

이면:

90 + 30 = 120
 

MaxHealth가 100이므로:

Health = 100
 

이 된다.

체력이 최대 체력을 초과하지 않도록 방어하는 코드이다.


5. 데미지 처리 — TakeDamage()

이번에 새롭게 배운 중요한 부분.

 
virtual float TakeDamage(
    float DamageAmount,
    FDamageEvent const& DamageEvent,
    AController* EventInstigator,
    AActor* DamageCauser
) override;
 

AActor에 이미 존재하는 TakeDamage()를 오버라이드했다.

즉 외부에서:

 
ApplyDamage()
 

등을 통해 데미지가 들어오면 캐릭터가 이 함수를 통해 데미지를 처리할 수 있다.


CPP

 
float AJunCharacter::TakeDamage(
    float DamageAmount,
    FDamageEvent const& DamageEvent,
    AController* EventInstigator,
    AActor* DamageCauser)
{
    float ActualDamage = Super::TakeDamage(
        DamageAmount,
        DamageEvent,
        EventInstigator,
        DamageCauser
    );

    Health = FMath::Clamp(
        Health - DamageAmount,
        0.0f,
        MaxHealth
    );

    UE_LOG(
        LogTemp,
        Warning,
        TEXT("Health decreased to: %f"),
        Health
    );

    if (Health <= 0.0f)
    {
        OnDeath();
    }

    return ActualDamage;
}
 

흐름

외부에서 데미지 발생
        ↓
TakeDamage()
        ↓
Health - DamageAmount
        ↓
Clamp로 0~MaxHealth 제한
        ↓
Health <= 0 ?
   ↓             ↓
  YES            NO
   ↓              ↓
OnDeath()       계속 플레이
 

6. 사망 처리

 
virtual void OnDeath();
 

CPP:

 
void AJunCharacter::OnDeath()
{
    UE_LOG(
        LogTemp,
        Error,
        TEXT("Character is Dead!")
    );
}
 

현재는 실제 사망 처리가 아니라 로그만 출력하도록 만들었다.

Health = 0
 ↓
OnDeath()
 ↓
"Character is Dead!"
 

나중에는 여기에:

  • 캐릭터 Destroy
  • 사망 애니메이션
  • Game Over UI
  • 리스폰

등을 넣을 수 있다.


7. HealingItem — 아이템이 플레이어의 체력을 회복

HealingItem에서는 아이템이 플레이어와 상호작용하도록 만들었다.

 
void AHealingItem::ActivateItem(AActor* Activator)
{
    if (Activator && Activator->ActorHasTag("Player"))
    {
        if (AJunCharacter* PlayerCharacter =
            Cast<AJunCharacter>(Activator))
        {
            PlayerCharacter->AddHealth(HealAmount);
        }

        DestroyItem();
    }
}
 

흐름

플레이어가 아이템과 충돌
        ↓
ActivateItem()
        ↓
Activator가 존재하는가?
        ↓
Player 태그가 있는가?
        ↓
JunCharacter로 Cast 가능한가?
        ↓
AddHealth()
        ↓
체력 회복
        ↓
DestroyItem()
 

여기서 중요한 것은 아이템이 직접 Health 변수에 접근하지 않는다는 것이다.

 
PlayerCharacter->AddHealth(HealAmount);
 

라는 함수를 호출한다.

즉:

HealingItem
     ↓
AddHealth()
     ↓
Character가 자기 Health를 변경
 

이 구조가 된다.


8. CoinItem — 아이템과 GameState의 상호작용

CoinItem에서는 플레이어가 코인을 획득했을 때 점수를 증가시키는 구조를 만들었다.

 
if (Activator && Activator->ActorHasTag("Player"))
{
    if (UWorld* World = GetWorld())
    {
        if (ASpartaGameStateBase* GameState =
            World->GetGameState<ASpartaGameStateBase>())
        {
            GameState->AddScore(PointValue);
        }
    }

    DestroyItem();
}
 

흐름은:

플레이어가 코인 획득
        ↓
Player 태그 확인
        ↓
World 가져오기
        ↓
GameState 가져오기
        ↓
AddScore(PointValue)
        ↓
점수 증가
        ↓
코인 Destroy
 

그런데 여기서 트러블슈팅 발생

현재 내 프로젝트에는:

 
SpartaGameStateBase.h
 

가 존재하지 않았다.

그래서:

C1083
Cannot open include file:
'SpartaGameStateBase.h'
 

에러가 발생했다.

강의 프로젝트에서 사용하는 클래스 이름을 내 프로젝트에 그대로 가져왔기 때문이다.

현재 아직 GameState를 구현하지 않았다면 해당 부분을 잠시 제외하고:

 
if (Activator && Activator->ActorHasTag("Player"))
{
    DestroyItem();
}
 

정도로 아이템 획득/삭제까지만 구현할 수 있다.


9. MineItem — 플레이어에게 데미지를 주는 아이템

지뢰는 HealingItem과 반대다.

HealingItem:

아이템 → Player → AddHealth()
 

MineItem:

아이템 → Player → TakeDamage()
 

지뢰의 핵심은 Timer를 이용한 지연 폭발이다.

 
ExplosionDelay = 5.0f;
 

그리고:

 
GetWorld()->GetTimerManager().SetTimer(
    ExplosionTimerHandle,
    this,
    &AMineItem::Explode,
    ExplosionDelay,
    false
);
 

이 코드는:

5초 후 Explode() 실행
 

이라는 뜻이다.

특히:

 
false
 

이므로 반복 타이머가 아니라 한 번만 실행된다.


10. 지뢰 폭발 → ApplyDamage()

 
UGameplayStatics::ApplyDamage(
    Actor,
    ExplosionDamage,
    nullptr,
    this,
    UDamageType::StaticClass()
);
 

이 부분이 오늘 배운 아이템 → 캐릭터 데미지 전달의 핵심이다.

지뢰가 직접:

 
Health -= 30;
 

하는 것이 아니다.

대신:

MineItem
   ↓
ApplyDamage()
   ↓
Player의 TakeDamage()
   ↓
Health 감소
   ↓
Health <= 0 ?
   ↓
OnDeath()
 

라는 구조를 만든다.

이렇게 하면 데미지를 주는 쪽과 데미지를 받는 쪽을 분리할 수 있다.


11. 아이템 시스템 전체 구조

오늘 배운 내용을 연결하면 이런 구조가 된다.

                    ┌──────────────┐
                    │   Player     │
                    │ JunCharacter │
                    └──────┬───────┘
                           │
                아이템과 상호작용
                           │
             ┌─────────────┼─────────────┐
             ↓             ↓             ↓
       HealingItem      CoinItem       MineItem
             │             │             │
             ↓             ↓             ↓
        AddHealth()     AddScore()   ApplyDamage()
             │             │             │
             ↓             ↓             ↓
          Health         Score       TakeDamage()
                                         │
                                         ↓
                                      Health 감소
                                         │
                                         ↓
                                      OnDeath()
 

이 구조가 오늘 배운 것 중에서 가장 중요한 부분이라고 생각한다.


12. 오늘의 트러블슈팅 🔧

① 유니코드 지옥

코드를 복사하는 과정에서:

->   → 
=    
(    
)    
<    
 

등으로 깨지는 현상이 발생했다.

예를 들어:

 
ExplosionCollision→SetupAttachment(Scene);
 

정상적인 C++는:

 
ExplosionCollision->SetupAttachment(Scene);
 

이다.

해결

깨진 코드를 그대로 사용하지 않고 정상적인 C++ 기호로 복구했다.


13. MineItem.h와 .cpp가 섞인 문제

오늘 가장 중요한 실수 중 하나.

MineItem.h에 있어야 하는:

 
UCLASS()

class AMineItem : public ABaseItem
{
    GENERATED_BODY()
    
    ...
};
 

가 사라지고 대신:

 
AMineItem::AMineItem()
{
    ...
}
 

같은 CPP 구현 코드가 들어가 있었다.

그래서:

"어? 왜 MineItem.h에 UCLASS가 없지?"
 

라는 이상한 상황이 발생했다.

정상 구조

MineItem.h

클래스 선언
UPROPERTY
UFUNCTION
함수 원형
 

MineItem.cpp

생성자 구현
함수 구현
실제 로직
 

14. generated.h 에러

C1083
Cannot open include file:
'MineItem.generated.h'
 

이 에러도 처음에는 generated.h 파일 자체가 문제인 것처럼 보였다.

하지만 generated.h는 내가 직접 만드는 파일이 아니라 UnrealHeaderTool이 UCLASS, UPROPERTY, UFUNCTION 등을 기반으로 생성하는 파일이다.

따라서:

 
#include "MineItem.generated.h"
 

자체가 잘못된 것이 아니라,

그 전에 헤더 구조가 깨져서 UHT가 정상적으로 처리하지 못했을 가능성을 먼저 봐야 한다.

그리고 .generated.h는 헤더의 마지막 include로 둔다.


15. C1014 too many include files

C1014
too many include files: depth = 1024
 

이 에러도 발생했다.

이는 헤더가 서로 무한히 include되는 순환 참조가 발생했을 때 나타날 수 있다.

예:

MineItem.h
 ↓
BaseItem.h
 ↓
MineItem.h
 ↓
BaseItem.h
 ↓
...
 

따라서:

 
MineItem.cpp
#include "MineItem.h"
 

자체가 문제인 것이 아니라,

헤더끼리 서로 물고 있는 구조가 문제라는 것을 알게 되었다.


16. 오늘 배운 핵심 정리

체력

 
MaxHealth
 

→ 최대 체력

 
Health
 

→ 현재 체력

 
AddHealth()
 

→ 체력 회복

 
TakeDamage()
 

→ 데미지 처리

 
OnDeath()
 

→ 체력이 0이 되었을 때 사망 처리


아이템 상호작용

HealingItem
→ AddHealth()
→ 체력 증가
 
MineItem
→ ApplyDamage()
→ TakeDamage()
→ 체력 감소
 
CoinItem
→ GameState
→ AddScore()
→ 점수 증가
 

Unreal C++에서 중요한 구조

.h
→ 무엇이 존재하는지 선언

.cpp
→ 실제로 어떻게 동작하는지 구현
 

그리고:

 
#include "MineItem.generated.h"
 

은 직접 만드는 파일이 아니라 UHT가 생성하는 파일이며, 반드시! 헤더의 마지막 include로 둔다.


📝 오늘의 한 줄 회고

오늘은 단순히 체력과 아이템 기능을 구현한 것뿐만 아니라, HealingItem → AddHealth(), MineItem → ApplyDamage() → TakeDamage(), CoinItem → GameState → AddScore()처럼 "아이템이 플레이어의 데이터를 직접 건드리는 것이 아니라 적절한 함수를 통해 상호작용한다"는 구조를 이해했다. 또한 유니코드 깨짐, .h/.cpp 혼동, generated.h 생성 문제 등의 트러블슈팅을 통해 Unreal C++에서 파일 구조와 프로젝트 클래스 관계가 얼마나 중요한지도 경험했다.