Unreal 게임 개발 과정 TIL

Today's I Learning

본캠프 TIL

06.23 Unreal 본캠프) 아이템 스폰 및 레벨 데이터 관리하기

Sunlight__ 2026. 6. 23. 20:55

 

1. 랜덤 위치에 아이템 스폰하기

 1) 레벨 세팅하기

 : Resources 폴더에 Maps 폴더에는 3개의 레벨이 이미 존재한다.

   각각 난이도에 따라서 크기가 다른 맵이고, BasicLevel - Intermediate Level - Advanced Level 순서로 크기가 작아진다.

- 위 3개의 레벨을 Content - Maps 폴더로 이동 시킨 후, 기존에 사용하던 MainLevel에서 BasicLevel을 기본 레벨로 지정한다.

 : Edit - Project Settings - Maps & Modes 에서 Default Maps를 BasicLevel로 변경

- MainLevel 은 삭제 한다.

 

 2) 콜리전 컴포넌트로 스폰 영역 지정하기

- 언리얼 엔진에서는 직접 지정한 박스(상자), 구체, 캡슐 등 원하는 형태의 '콜리전 컴포넌트' 내부에서 임의의 좌표를 뽑아 아이템(액터)을 스폰할 수 있다.
- 가장 간단한 방법으로, 콜리전 컴포넌트인 UBoxComponent를 활용할 것이다.
- 우선, 새로운 C++ 클래스 Actor를 상속한 클래스로, SpawnVolume 이라는 이름으로 클래스를 하나 생성한다.
#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "SpawnVolume.generated.h"

class UBoxComponent;

UCLASS()
class SPARTAPROJECT_API ASpawnVolume : public AActor
{
    GENERATED_BODY()
    
public:
    ASpawnVolume();

UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="Spawning")
    USceneComponent* Scene;
    // 스폰 영역을 담당할 박스 컴포넌트
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="Spawning")
    UBoxComponent* SpawningBox;

    // 스폰 볼륨 내부에서 무작위 좌표를 얻어오는 함수
    UFUNCTION(BlueprintCallable, Category="Spawning")
    FVector GetRandomPointInVolume() const;
    // 특정 아이템 클래스를 스폰하는 함수
    UFUNCTION(BlueprintCallable, Category="Spawning")
    void SpawnItem(TSubclassOf<AActor> ItemClass);
};

SpawnVolume.h

- UBoxComponent* SpawningBox;
 : 이 컴포넌트가 박스 형태의 콜리전 영역을 나타낸다.
   언리얼 엔진의 UBoxComponent는 박스 내부에서 오버랩(Overlap)이나 충돌(Collision)감지할 수 있는 컴포넌트이다.
   실제로 눈에 보이는 3D 메시는 아니며, "박스 형태의 충돌 범위" 만 제공한다.
- GetRandomPointInVolume()
 : SpawningBox 범위 내부에서 랜덤 좌표 (FVector)를 리턴한다.
   이 좌표를 이용해 아이템을 생성하면 "직육면체 형태 범위 안에서" 임의의 위치에 아이템이 뜨는 효과를 낼 수 있다.
- SpawnItem()
 : 파라미터로 받은 아이템 클래스를 SpawnVolume 내부의 랜덤 위치에 생성 (SpawnActor) 하는 함수이다.
#include "SpawnVolume.h"
#include "Components/BoxComponent.h"
#include "Engine/World.h"
#include "GameFramework/Actor.h"

ASpawnVolume::ASpawnVolume()
{
    PrimaryActorTick.bCanEverTick = false;

    // 박스 컴포넌트를 생성하고, 이 액터의 루트로 설정
    Scene = CreateDefaultSubobject<USceneComponent>(TEXT("Scene"));
    SetRootComponent(Scene);
    
    SpawningBox = CreateDefaultSubobject<UBoxComponent>(TEXT("SpawningBox"));
    SpawningBox->SetupAttachment(Scene);
}

FVector ASpawnVolume::GetRandomPointInVolume() const
{
    // 1) 박스 컴포넌트의 스케일된 Extent, 즉 x/y/z 방향으로 반지름(절반 길이)을 구함
    FVector BoxExtent = SpawningBox->GetScaledBoxExtent();
    // 2) 박스 중심 위치
    FVector BoxOrigin = SpawningBox->GetComponentLocation();

    // 3) 각 축별로 -Extent ~ +Extent 범위의 무작위 값 생성
    return BoxOrigin + FVector
  (
        FMath::FRandRange(-BoxExtent.X, BoxExtent.X),
        FMath::FRandRange(-BoxExtent.Y, BoxExtent.Y),
        FMath::FRandRange(-BoxExtent.Z, BoxExtent.Z)
    );
}

void ASpawnVolume::SpawnItem(TSubclassOf<AActor> ItemClass)
{
if (!ItemClass) return;

    GetWorld()->SpawnActor<AActor>
    (
        ItemClass,
        GetRandomPointInVolume(),
        FRotator::ZeroRotator
    );
}

SpawnVolume.cpp

- GetScaleBoxExtent()
 : 박스 컴포넌트의 실제 "가로/세로/높이 절반 길이(Extent)" 를 반환한다.
   에디터에서 Scale을 조절하면 여기에도 반영된다.
- FRandRange(a, b)
 : a ~ b 사이의 임의의 float 값을 반환한다.
   여기서는 X, Y, Z 좌표마다 랜덤 값을 생성하여 BoxOrigin에 더해준다.

 

 3) 아이템 랜덤 스폰 테스트하기

- 빌드를 하고 언리얼 에디터로 돌아와서 이 SpawnVolume 클래스를 상속받은 BP_SpawnVolume Blueprint 클래스를 생성한다.
- BP_SpawnVolume 을 레벨에 드래그해 놓고, 적적히 위치와 스케일을 조정한다.

 - BP_SpawnVolume의 이벤트 그래프에서 Begin Play 노드를 연결하고, 그 다음에 C++의 SpawnItem() 함수를 호츨하는 Blueprint 노드를 추가한다.

 

2. 아이템 스폰 확률 데이터 테이블 만들기

 1) Item Data 구조체 만들기

- 어떤 아이템이 몇 % 확률로 스폰되는지를 코드로 직접 하드코딩하면, 매번 수정할 때마다 빌드를 해줘야 해서 번거롭다.
- 언리얼 엔진의 데이터 테이블을 사용하면, 이를 엑셀 (CSV)이나 JSON 파일로 관리해서 엔진 안으로 임포트하고, 코드나 블루프린트에서 쉽게 불러 쓸 수 있다. 
  기획자나 디자이너도 엑셀에서 숫자만 바꾸면 되므로 매우 편리하다.
- 우선, 데이터 테이블의 각 "행(Row)" 을 C++ 구조체로 매핑해야 한다.
  언리얼 엔진은 FTableRowBase라는 기본 구조체를 제공하며, 이를 상속한 구조체를 만들면, 각 CSV(또는 JSON) 행을 FItemSpanwRow 구조체에서 정해준 형태로 받아올 수 있게 된다.
- 이러한 구조는 클래스가 아니기 때문에, 부모 클래스를 선택할 수 있는 옵션에 나타나지 않는다.
  따라서 None을 선택해서 ItemSpawnRow C++ 파일을 만들어 준다.
#pragma once

#include "CoreMinimal.h"
#include "Engine/DataTable.h"     // FTableRowBase 정의가 들어있는 헤더
#include "ItemSpawnRow.generated.h"

USTRUCT(BlueprintType)
struct FItemSpawnRow : public FTableRowBase
{
    GENERATED_BODY()

public:
    // 아이템 이름
    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    FName ItemName;
    // 어떤 아이템 클래스를 스폰할지
    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    TSubclassOf<AActor> ItemClass;
    // 이 아이템의 스폰 확률
    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    float SpawnChance;
};

SpawnVolume.cpp

- FTableRowBase
 : 언리얼 엔진에서 "이 구조체는 데이터 테이블로 쓸 수 있다" 라고 인식하게 해주는 베이스 구조체이다.
- TSubclassOf
 : 하드 래퍼런스로, 클래스가 항상 로드된 상태에서 바로 접근한다.
- TSoftClassPtr<AActor>
 : 소프트 래퍼런스로, 클래스를 바로 로드하지 않고도 경로만 기억해둘 수 있다.
   필요할 때 Get() 을 통해 실제 UClass* 를 얻어 인스턴스를 생성할 수 있다.

 

 2) CSV 파일 작성 및 임포트하기 (방법 1)

- 액셀을 열고 2열부터 Column 이름을 작성한다.
- 1열은 "RowName" 으로 인식된다.
  Key 값으로 사용할 값을 입력해줘야 한다.
- 2열부터는 각 아이템(데이터) 정보를 한 줄씩 기록한다.
- 경로는 각 BP 클래스 우클릭 -> Copy Reference 로 정확히 가져온다.
  그리고 블루프린트의 경우 "_C" 가 붙어야 BP 클래스로 인식되니까 마지막에 반드시 붙여줘야 한다.

- Copy Reference언리얼 엔진 런타임에서 사용되는 "클래스/오브젝트" 경로이다.

 : 블루프린트/C++ 로 자산 로드 시 ConstructorHelpers::FClassFinder 등에서 활용한다.

- CSV 로 저장
 : 언리얼 콘텐츠 폴더가 아니라 본인이 편한 로컬 위치에 파일명 ItemSpawnTable.csvCSV UTF-8 (쉼표로 분리)로 저장한다.

 - 언리얼 에디터로 돌아와, Content Browser에서 Blueprints 폴더에서 우클릭 -> Import to /Game/ Blueprints 를 선택, 아래와 같이 옵션을 설정하고 Apply를 클릭한다.

- 그러면 다음과 같이 성공적으로 DataTable이 만들어진 것을 볼 수 있다.

 

 3) 언리얼 에디터에서 직접 데이터 테이블 수정하기 (방법 2)

 : CSV를 쓰는 대신, 언리얼 에디터 내에서 직접 편집하는 방법도 있다.

1. Content Browser 창에서 우클릭 -> Miscellaneous -> Data Table 선택
2. 팝업창에서 Row StructureFItemSpawnRow를 선택하고 OK
3. 새로 생성된 DataTable 에셋을 열면, 에디터 UI 상에서 아래처럼 Row 정보를 바로 편집 가능하다.
 - Add Row 버튼을 눌러 행을 추가하고, Row Name, ItemName, ItemClass, SpawnChance 등을 원하는 값으로 넣는다.

- 하지만 이런 방식은 Excel처럼 대량의 데이터를 한 번에 편집하기는 불편할 수 있다.
- 상황에 따라 에디터 직접 편집 / CSV 임포트 모두 필요에 따라 병행할 수도 있다.

 

3. 스폰에 확률 적용하기

 1) 데이터 테이블을 사용하여 스폰 로직 구현하기

 : SpawnVolume 클래스로 돌아와서, 확률 로직을 "어떤 아이템을 스폰할지" 결정하는 코드로 다시 수정을 한다.

#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "ItemSpawnRow.h"       // 우리가 정의한 구조체
#include "SpawnVolume.generated.h"

class UBoxComponent;

UCLASS()
class SPARTAPROJECT_API ASpawnVolume : public AActor
{
    GENERATED_BODY()

public:
    ASpawnVolume();

    UFUNCTION(BlueprintCallable, Category = "Spawning")
    void SpawnRandomItem();

protected:
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="Spawning")
    USceneComponent* Scene;
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Spawning")
    UBoxComponent* SpawningBox;

    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Spawning")
    UDataTable* ItemDataTable;

    FVector GetRandomPointInVolume() const;
    FItemSpawnRow* GetRandomItem() const;
    void SpawnItem(TSubclassOf<AActor> ItemClass);
};

SpawnVolume.h

#include "SpawnVolume.h"
#include "Components/BoxComponent.h"
#include "Engine/World.h"

ASpawnVolume::ASpawnVolume()
{
    PrimaryActorTick.bCanEverTick = false;

    Scene = CreateDefaultSubobject<USceneComponent>(TEXT("Scene"));
    SetRootComponent(Scene);
    
    SpawningBox = CreateDefaultSubobject<UBoxComponent>(TEXT("SpawningBox"));
    SpawningBox->SetupAttachment(Scene);
    
    ItemDataTable = nullptr;
}

void ASpawnVolume::SpawnRandomItem()
{
    if (FItemSpawnRow* SelectedRow = GetRandomItem())
    {
        if (UClass* ActualClass = SelectedRow->ItemClass.Get())
        {
            SpawnItem(ActualClass);
        }
    }
}

FVector ASpawnVolume::GetRandomPointInVolume() const
{
    const FVector BoxExtent = SpawningBox->GetScaledBoxExtent();
    const FVector BoxOrigin = SpawningBox->GetComponentLocation();

    return BoxOrigin + FVector(
        FMath::FRandRange(-BoxExtent.X, BoxExtent.X),
        FMath::FRandRange(-BoxExtent.Y, BoxExtent.Y),
        FMath::FRandRange(-BoxExtent.Z, BoxExtent.Z)
    );
}

FItemSpawnRow* ASpawnVolume::GetRandomItem() const
{
    if (!ItemDataTable) return nullptr;

    // 1) 모든 Row(행) 가져오기
    TArray<FItemSpawnRow*> AllRows;
    static const FString ContextString(TEXT("ItemSpawnContext"));
    ItemDataTable->GetAllRows(ContextString, AllRows);

    if (AllRows.IsEmpty()) return nullptr;

    // 2) 전체 확률 합 구하기
    float TotalChance = 0.0f;     // 초기화
    for (const FItemSpawnRow* Row : AllRows)     // AllRows 배열의 각 Row를 순회
    {
        if (Row)     // Row가 유효한지 확인
        {
            TotalChance += Row->SpawnChance;     // SpawnChance 값을 TotalChance에 더하기
        }
    }

    // 3) 0 ~ TotalChance 사이 랜덤 값
    const float RandValue = FMath::FRandRange(0.0f, TotalChance);
    float AccumulateChance = 0.0f;

    // 4) 누적 확률로 아이템 선택
    for (FItemSpawnRow* Row : AllRows)
    {
        AccumulateChance += Row->SpawnChance;
        if (RandValue <= AccumulateChance)
        {
            return Row;
        }
    }

    return nullptr;
}

void ASpawnVolume::SpawnItem(TSubclassOf<AActor> ItemClass)
{
    if (!ItemClass) return;

    GetWorld()->SpawnActor<AActor>(
        ItemClass,
        GetRandomPointInVolume(),
        FRotator::ZeroRotator
    );
}

SpawnVolume.cpp

- SpawnRandomItem()
 : "데이터 테이블에서 확률 계산으로 Row 하나 선택" + "스폰" 을 분리해놓아 코드가 깔끔해진다.
- GetRandomItem()
 : 테이블의 모든 Row를 가져와 확률합을 구하고, 확률에 따라 한 개를 뽑아 리턴한다.
- Algo::Accumulate
 : 언리얼 엔진에서 추가한 C++ 템플릿 함수로, STL의 std::accumulate 와 유사하게 작동한다.
   각 Row의 SpawnChance를 계속 더해 총 합을 구한다.
   "배열 안에 있는 모든 값을 하나로 누적해서 합쳐주는" 함수이다.

 

 - 빌드 후, BP_SpawnVolume 로 돌아가서, 속성에서 Item Data Table에도 기존에 만들어 둔 데이터 테이블인 ItemSpawnTable 에셋을 할당한다.

 

 - 이벤트 그래프 SpawnRandomItem() 함수를 스크린샷과 같이 10번의 호출을 하도록 로직 노드를 생성한다.

 

 2) 레벨마다 다른 확률 데이터 적용하기

 : 원래 기획 취지에 의하면 레벨별로 다른 확률을 가져야 한다.

   이를 구현하는 방법은 여러 가지가 있다.

1. 레벨별로 서로 다른 DataTable 에셋을 사용
 - CSV(또는 JSON)에 각각 다른 확률을 넣고, 레벨 1 맵에서는 BP_SpawnVolumeItemSpawnTable_Level1 을 바라보도록 설정.
 - 레벨 2 맵에서는 ItemSpawnTable_Level2 할당... 등등
 - 관리가 직관적이며 간단하다.
2. 단일 DataTable에 "레벨 구분" 컬럼 추가
 - 예 : CSV에 LevelIndex (또는 LevelName) 같은 컬럼을 하나 더 두고, 각각 어느 레벨에 해당하는지 적는다.
 - 코드를 수정하여, 현재 레벨이 1인지 2인지 확인 후 "필터링된 Row" 만 확률 계산.
 - 한 DataTable에서 모든 레벨 정보를 한 번에 관리할 수 있는 장점이 있으나, 코드가 약간 복잡해진다.

- 실무나 개인 프로젝트에서 상황에 맞춰 자유롭게 선택하면 된다.