오늘은 게임의 핵심 시스템 중 하나인 아이템 시스템을 설계했다. 먼저 C++ 인터페이스(Interface)를 활용해 확장성 높은 아이템 클래스 구조를 설계하고, 이를 상속받아 코인, 힐링 아이템, 지뢰 등 다양한 아이템 클래스를 만들었다. 이후 아이템을 레벨에 무작위로 스폰시키기 위해 데이터 테이블(Data Table)을 이용해 스폰 확률을 관리하는 방법을 배웠다. 마지막으로 캐릭터의 체력 및 점수 시스템을 구현하여 아이템과 상호작용했을 때 실제 게임에 영향을 미치도록 만들었다. 🤩
BaseItem 부모 클래스와 이를 상속받는 다양한 자식 아이템(Coin, Healing, Mine) 구현하기SpawnVolume 액터를 만들어 데이터 테이블 기반으로 아이템을 랜덤 스폰하기ApplyDamage/TakeDamage 시스템 활용하기다양한 아이템(코인, 지뢰, 힐링)은 각자 고유한 기능을 가지지만, "플레이어와 상호작용한다"는 공통된 특징이 있다. 이럴 때 인터페이스가 빛을 발한다.
ActivateItem 함수를 호출한다"는 통일된 규칙을 만들고 싶다. 따라서 상속보다는 "상호작용이 가능하다"는 약속을 제공하는 인터페이스가 훨씬 유연하고 확장성 있는 설계다.게임의 밸런스를 잡기 위해 아이템 스폰 확률, 데미지, 점수 등을 계속 수정해야 한다. 이런 값들을 C++ 코드에 하드코딩하면 수정할 때마다 컴파일해야 해서 매우 비효율적이다.
데이터 테이블은 이런 데이터를 엑셀 시트처럼 관리하게 해주는 에셋이다.
1. C++에서 USTRUCT를 만들고 FTableRowBase를 상속받아 테이블의 열(Column) 구조를 정의한다.
2. 에디터에서 이 구조체를 기반으로 데이터 테이블 에셋을 생성한다.
3. 기획자나 디자이너는 코드를 몰라도 이 테이블만 열어서 값을 쉽게 수정하고 테스트할 수 있다.
데이터 테이블에 "코인 70%, 힐링 20%, 지뢰 10%"와 같은 확률을 설정했을 때, 이 확률에 따라 아이템을 뽑는 방법이다.
1. 모든 아이템의 확률 값을 더해 총 확률(Total Chance)을 구한다. (ex: 70+20+10 = 100)
2. 0부터 총 확률 사이의 랜덤 숫자를 하나 뽑는다. (ex: 55)
3. 아이템 목록을 순회하며 확률을 누적(Accumulate)해 나간다.
- 코인: 누적 확률 70. (랜덤 숫자 55는 이 구간에 속함)
- -> 코인 당첨!
4. 만약 랜덤 숫자가 85였다면?
- 코인: 누적 70 (85보다 작음 -> 통과)
- 힐링: 누적 70+20=90 (랜덤 숫자 85는 이 구간에 속함)
- -> 힐링 당첨!
ApplyDamage / TakeDamage: 언리얼이 제공하는 표준 데미지 처리 방식이다. 데미지를 주는 쪽(지뢰)은 ApplyDamage를 호출하고, 데미지를 받는 쪽(캐릭터)은 TakeDamage 함수를 오버라이드해서 실제 체력을 깎는 로직을 구현한다. 이렇게 역할을 분리하면 코드가 깔끔해진다.GameState vs PlayerState: 게임의 '상태'를 저장하는 클래스들이다.GameState: 게임 전체에 공유되는 전역 정보 (ex: 현재 게임 시간, 총점)를 저장한다.PlayerState: 각 플레이어 개개인의 정보 (ex: 플레이어 이름, 킬/데스)를 저장한다.GameState에 구현하는 것이 적합하다.#pragma once
#include "CoreMinimal.h"
#include "UObject/Interface.h"
#include "ItemInterface.generated.h"
UINTERFACE(MinimalAPI)
class UItemInterface : public UInterface
{
GENERATED_BODY()
};
class SPARTAPROJECT_API IItemInterface
{
GENERATED_BODY()
public:
// 이 인터페이스를 구현하는 클래스는 아래 함수들을 반드시 만들어야 한다는 '계약'
virtual void ActivateItem(AActor* Activator) = 0; // 아이템 효과 발동 함수
virtual FName GetItemType() const = 0; // 아이템 타입을 반환하는 함수
};
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "ItemInterface.h" // 인터페이스 포함
#include "BaseItem.generated.h"
UCLASS()
class SPARTAPROJECT_API ABaseItem : public AActor, public IItemInterface // 액터이면서, 아이템 인터페이스를 구현함
{
GENERATED_BODY()
public:
ABaseItem();
protected:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Item")
FName ItemType; // 아이템 타입을 저장할 변수
public:
// 인터페이스의 함수들을 오버라이드하여 구현
virtual void ActivateItem(AActor* Activator) override;
virtual FName GetItemType() const override;
// 모든 아이템이 공통으로 사용할 소멸 함수
UFUNCTION(BlueprintCallable)
void DestroyItem();
};
#include "CoinItem.h"
#include "SpartaGameStateBase.h" // GameState를 사용하기 위해 포함
using namespace std;
ACoinItem::ACoinItem()
{
// 생성자에서 아이템 타입과 점수 초기화
ItemType = FName(TEXT("Coin"));
PointValue = 10;
}
// 코인 획득 시 호출될 함수
void ACoinItem::ActivateItem(AActor* Activator)
{
// 부모의 함수를 먼저 호출 (필요 시)
Super::ActivateItem(Activator);
// 1. 현재 월드의 GameState를 가져온다.
if (ASpartaGameStateBase* GameState = GetWorld()->GetGameState<ASpartaGameStateBase>())
{
// 2. GameState의 점수 추가 함수를 호출한다.
GameState->AddScore(PointValue);
}
// 3. 아이템을 소멸시킨다.
DestroyItem();
}
#include "MineItem.h"
#include "Kismet/GameplayStatics.h" // ApplyDamage 함수를 위해 포함
#include "DrawDebugHelpers.h"
void AMineItem::Explode()
{
// ... (폭발 이펙트, 사운드 코드) ...
// 1. 폭발 범위 내에 있는 액터들을 감지하기 위한 배열
TArray<AActor*> OverlappingActors;
// 2. 폭발 범위(구체)와 겹치는 액터들을 찾아 배열에 담는다.
// 이 때, 캐릭터만 감지하도록 필터링 (ECC_Pawn)
UKismetSystemLibrary::SphereOverlapActors(GetWorld(), GetActorLocation(), ExplosionRadius,
{ UEngineTypes::ConvertToObjectType(ECollisionChannel::ECC_Pawn) }, nullptr, {}, OverlappingActors);
// 3. 겹친 모든 액터들에게 데미지를 입힌다.
for (AActor* OverlappedActor : OverlappingActors)
{
UGameplayStatics::ApplyDamage(
OverlappedActor, // 데미지 받을 액터
ExplosionDamage, // 데미지 양
nullptr, // 데미지 유발자 컨트롤러 (없음)
this, // 실제 데미지를 입힌 원인 (지뢰 자신)
nullptr // 데미지 타입 (기본)
);
}
DestroyItem(); // 지뢰 소멸
}
#include "SpartaCharacter.h"
#include "Kismet/GameplayStatics.h"
// 캐릭터가 데미지를 받았을 때 호출되는 함수 (AActor로부터 오버라이드)
float ASpartaCharacter::TakeDamage(float DamageAmount, FDamageEvent const& DamageEvent, AController* EventInstigator, AActor* DamageCauser)
{
// 1. 부모 클래스의 TakeDamage를 먼저 호출하여 기본 처리 및 실제 데미지 값을 받음
const float ActualDamage = Super::TakeDamage(DamageAmount, DamageEvent, EventInstigator, DamageCauser);
if (ActualDamage > 0.f)
{
// 2. 실제 데미지만큼 현재 체력을 감소시킴
Health -= ActualDamage;
// 3. 체력이 0과 최대 체력 범위를 벗어나지 않도록 Clamp
Health = FMath::Clamp(Health, 0.f, MaxHealth);
UE_LOG(LogTemp, Warning, TEXT("Health decreased: %f"), Health);
if (Health <= 0.f)
{
// 4. 체력이 0 이하면 사망 처리
OnDeath();
}
}
return ActualDamage;
}
IItemInterface를 상속받고 헤더에 virtual void ActivateItem(AActor* Activator) override;를 선언해놓고, 정작 cpp 파일에 함수 본체를 구현하는 것을 잊었다. 순수 가상 함수(= 0)로 선언했기 때문에 컴파일러가 바로 링크 에러를 띄워줘서 다행히 금방 찾을 수 있었다.RowName의 중요성 간과: 데이터 테이블에 아이템 정보를 추가할 때, 내가 보기 편한 ItemName 필드만 신경 쓰고 RowName(가장 왼쪽의 고유 ID)을 대충 "NewRow" 그대로 뒀다. 나중에 C++에서 특정 행을 가져오려고 할 때 RowName을 기준으로 찾는다는 것을 깨닫고 모두 고유한 이름으로 수정했다. RowName이 Primary Key라는 것을 명심해야 한다.GetGameState 캐스팅 실패: GetWorld()->GetGameState()는 기본 AGameStateBase 포인터를 반환한다.ASpartaGameStateBase의 AddScore 함수를 호출하려면 반드시 <ASpartaGameStateBase> 템플릿을 사용하거나, 반환된 포인터를 직접 캐스팅해야 했다. 이걸 잊고 기본 포인터에서 함수를 호출하려다 컴파일 에러가 발생했다.| 개념 | 설명 | 비고 |
|---|---|---|
| 인터페이스 (Interface) | 특정 함수들을 반드시 구현하도록 강제하는 '계약' 또는 '약속'. | 클래스는 여러 인터페이스를 동시에 구현할 수 있다. |
| 데이터 테이블 (Data Table) | 게임 데이터를 코드와 분리하여 엑셀처럼 관리하는 에셋. | USTRUCT + FTableRowBase 상속이 필수. |
| 누적 확률 알고리즘 | 각 항목의 확률을 더해가며 구간을 만들고, 랜덤 값으로 당첨 항목을 뽑는 방식. | 가챠, 아이템 드랍 등 확률 기반 시스템의 기본. |
| 스폰 볼륨 (Spawn Volume) | BoxComponent 등을 이용해 월드에 특정 영역을 지정하고, 그 안에서 액터를 생성하는 액터. | 랜덤 스폰 위치를 정할 때 유용하다. |
ApplyDamage | 데미지를 주는 쪽에서 호출하는 전역 함수. | UGameplayStatics 헤더 필요. |
TakeDamage | 데미지를 받는 쪽에서 오버라이드하여 실제 로직을 구현하는 함수. | AActor의 가상 함수. |
GameState | 게임 전체의 전역 정보(총점, 남은 시간 등)를 관리하는 클래스. | 싱글플레이어 게임의 점수 관리에 적합. |