[UE5] Lena: Dev Diary #13 - 아이템 관리와 월드 스폰 시스템

ChangJin·2024년 7월 12일
0

Unreal Engine5

목록 보기
81/102
post-thumbnail

2024.07.13

깃허브!풀리퀘!
https://github.com/ChangJin-Lee/Project-Lena https://github.com/ChangJin-Lee/Project-Lena/pull/13

이번 포스팅에서는 아이템 관리와 월드 스폰 시스템을 구현하는 방법을 다룹니다. 인벤토리 시스템, 아이템의 특성 관리, 아이템 스폰 시스템 등을 중점적으로 설명합니다.

진행상황

  • ✅ 아이템 관리 시스템
    • ✅ 아이템 구조체 및 데이터 테이블 생성
    • ✅ 인벤토리 시스템 구현
    • ✅ 아이템 추가, 삭제, 검색 기능 구현
  • ✅ 월드 스폰 시스템
    • ✅ 스폰 포인트 액터 배치
    • ✅ 스폰 데이터 테이블 생성
    • ✅ 아이템 스폰 로직 구현
  • ✅ 아이템 상호작용 기능 추가
아이템 관리 시스템 구성도
업데이트 된 상호작용 관련 상속 관계도

만들고자 하는 아이템 스폰 시스템
기존에 동작하고 있던 방식새롭게 하려고 하는 방식생각한 매핑테이블

기존에는 1개의 문에 1개의 자물쇠가 연결되어서 비밀번호를 정확하게 맞추면 문이 열리게끔 했는데 이렇게 하면 강한 연결이 되므로 다양한 퍼즐 기믹을 추가하는데 매우 어려워집니다.

대략 이런 느낌으로 데이터를 관리하고 싶었습니다.

LockIDDoorIDTypePasswordRequiredItemID
DirectionalLock0SlidingDoor0Lock1234
DirectionalLock1TrapDoorItemKeyItem1
DirectionalLock2SecretDoorItemKeyItem2
DirectionalLock3SlidingDoor1Lock5678

다양한 로직들을 더 유연하게 만들기 위해서 언리얼 엔진의 데이터 테이블로 문을 열때 열쇠 아이템이 필요한지, 아니면 자물쇠의 비밀번호를 맞춰야하는지 등을 나타내야 했습니다. 그러다보니 자연스레 아이템을 관리해야했고 인벤토리 시스템을 만들게 되었습니다.

그리고 다음처럼 CSV 파일로 만들어서 임포트도 가능한 것을 확인했습니다.

DoorID,RequiredLocks
SlidingDoor0,"[{""LockID"": ""DirectionalLock0"", ""Password"": ""1234"", ""RequiredItemID"": """"}, {""LockID"": ""DirectionalLock1"", ""Password"": ""5678"", ""RequiredItemID"": """"}]"
TrapDoor,"[{""LockID"": ""DirectionalLock2"", ""Password"": """", ""RequiredItemID"": ""KeyItem1""}]"
SecretDoor,"[{""LockID"": ""DirectionalLock3"", ""Password"": """", ""RequiredItemID"": ""KeyItem2""}]"
SlidingDoor1,"[{""LockID"": ""DirectionalLock0"", ""Password"": ""1234"", ""RequiredItemID"": """"}, {""LockID"": ""DirectionalLock1"", ""Password"": ""5678"", ""RequiredItemID"": """"}]"

매핑 테이블

1. 데이터 테이블 및 구조체 정의

데이터 테이블에서 만들고자하는 테이블을 작성해주고 언리얼 엔진에서 DataTable로 만들어 줍니다. 이때 조심해야할 점은 모든 액터가 생성이 되고 난 후에 월드에 존재하는 Door, Lock을 매핑하려고 하기 때문에 SetTimerForNextTick를 사용하여 월드의 모든 액터가 생성된 후 매핑을 해야합니다.

void AInteractManager::BeginPlay()
{
	Super::BeginPlay();
	GetWorld()->GetTimerManager().SetTimerForNextTick(this, &AInteractManager::SetupLockAndDoor);
}

매핑을 할때 InteractManager의 생성자 부분에서 다음을 사용하여 DataTable을 성공적으로 임포트해주어야합니다.

if (!LockDoorMappingTable)
    {
        static ConstructorHelpers::FObjectFinder<UDataTable> LockDoorMappingData(TEXT("DataTable'/Game/Data/DataTables/InteractConditionTable.InteractConditionTable'"));
        if (LockDoorMappingData.Succeeded())
        {
            LockDoorMappingTable = LockDoorMappingData.Object;
        }
    }

여기서 static 키워드를 사용하는 이유는 다음과 같습니다.

  1. 효율성: static 키워드를 사용하면 이 코드가 클래스의 모든 인스턴스에서 공유됩니다. 따라서, 생성자 호출마다 새롭게 찾지 않고, 클래스 로드 시 한 번만 실행됩니다.
  2. 성능: ConstructorHelpers::FObjectFinder는 주로 편집기에서만 사용되며, 게임이 실행될 때는 실행되지 않도록 설계되었습니다. 이는 성능 최적화를 위해 필요합니다.
  3. 편리함: ConstructorHelpers를 사용하면 에디터에서 자산을 쉽게 찾을 수 있습니다. 이 방법은 주로 C++ 클래스의 생성자에서 자산을 설정할 때 사용됩니다.

아이템 관리 시스템

1. 데이터 테이블 및 구조체 정의

아이템의 특성들을 관리하기 위해 데이터 테이블을 사용합니다. 각 아이템의 특성을 구조체로 정의하고, 이를 데이터 테이블로 관리합니다.

아이템 구조체 정의

#pragma once

#include "CoreMinimal.h"
#include "Engine/DataTable.h"
#include "ItemData.generated.h"

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

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Item")
    FString ItemID;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Item")
    FString ItemName;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Item")
    FString ItemDescription;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Item")
    int32 Quantity;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Item")
    float Weight;

    FItemData()
        : ItemID(TEXT("")), ItemName(TEXT("")), ItemDescription(TEXT("")), Quantity(1), Weight(0.0f)
    {}
};

아이템 데이터 테이블 생성

데이터 테이블은 Unreal Engine 에디터에서 FItemData 구조체를 기반으로 생성할 수 있습니다. 데이터 테이블은 아이템의 특성을 정의하고 관리하는 데 사용됩니다.

아이템 데이터 테이블 예시

2. 인벤토리 시스템 구현

인벤토리 시스템은 아이템을 추가, 삭제, 검색할 수 있는 기능을 포함합니다.

인벤토리 클래스 헤더

#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "ItemData.h"
#include "Inventory.generated.h"

UCLASS()
class MYGAME_API AInventory : public AActor
{
    GENERATED_BODY()
    
public:
    AInventory();

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Inventory")
    TArray<FItemData> Items;

    UFUNCTION(BlueprintCallable, Category="Inventory")
    void AddItem(const FItemData& Item);

    UFUNCTION(BlueprintCallable, Category="Inventory")
    bool RemoveItem(const FString& ItemID);

    UFUNCTION(BlueprintCallable, Category="Inventory")
    bool UpdateItemQuantity(const FString& ItemID, int32 NewQuantity);

    UFUNCTION(BlueprintCallable, Category="Inventory")
    FItemData FindItemByID(const FString& ItemID);

    UFUNCTION(BlueprintCallable, Category="Inventory")
    bool DoesItemExist(const FString& ItemID);
};

인벤토리 클래스 구현

#include "Inventory.h"

AInventory::AInventory()
{
    PrimaryActorTick.bCanEverTick = false;
}

void AInventory::AddItem(const FItemData& Item)
{
    FItemData* ExistingItem = Items.FindByPredicate([&](const FItemData& Existing) { return Existing.ItemID == Item.ItemID; });
    if (ExistingItem)
    {
        ExistingItem->Quantity += Item.Quantity;
    }
    else
    {
        Items.Add(Item);
    }
}

bool AInventory::RemoveItem(const FString& ItemID)
{
    for (int32 i = 0; i < Items.Num(); ++i)
    {
        if (Items[i].ItemID == ItemID)
        {
            Items.RemoveAt(i);
            return true;
        }
    }
    return false;
}

bool AInventory::UpdateItemQuantity(const FString& ItemID, int32 NewQuantity)
{
    FItemData* Item = Items.FindByPredicate([&](const FItemData& Existing) { return Existing.ItemID == ItemID; });
    if (Item)
    {
        Item->Quantity = NewQuantity;
        return true;
    }
    return false;
}

FItemData AInventory::FindItemByID(const FString& ItemID)
{
    FItemData* Item = Items.FindByPredicate([&](const FItemData& Existing) { return Existing.ItemID == ItemID; });
    return Item ? *Item : FItemData();
}

bool AInventory::DoesItemExist(const FString& ItemID)
{
    return Items.ContainsByPredicate([&](const FItemData& Existing) { return Existing.ItemID == ItemID; });
}

3. 캐릭터와 인벤토리 연동

캐릭터가 아이템을 주웠을 때 인벤토리에 아이템을 추가하는 기능을 구현합니다.

캐릭터 클래스 헤더

#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Character.h"
#include "Inventory.h"
#include "BaseCharacter.generated.h"

UCLASS()
class MYGAME_API ABaseCharacter : public ACharacter
{
    GENERATED_BODY()

public:
    ABaseCharacter();

protected:
    virtual void BeginPlay() override;

public:
    virtual void Tick(float DeltaTime) override;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Inventory")
    AInventory* Inventory;

    UFUNCTION(BlueprintCallable, Category="Inventory")
    void PickupItem(const FItemData& Item);

    UFUNCTION(BlueprintCallable, Category="Inventory")
    void InteractWithItem(AActor* ItemActor);
};

캐릭터 클래스 구현

#include "BaseCharacter.h"

ABaseCharacter::ABaseCharacter()
{
    PrimaryActorTick.bCanEverTick = true;

    // 인벤토리 초기화
    Inventory = CreateDefaultSubobject<AInventory>(TEXT("Inventory"));
}

void ABaseCharacter::BeginPlay()
{
    Super::BeginPlay();
}

void ABaseCharacter::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);
}

void ABaseCharacter::PickupItem(const FItemData& Item)
{
    if (Inventory)
    {
        Inventory->AddItem(Item);
    }
}

void ABaseCharacter::InteractWithItem(AActor* ItemActor)
{
    if (ItemActor)
    {
        // ItemActor에서 필요한 데이터를 추출하여 FItemData 구조체를 만듭니다.
        FItemData NewItem;
        NewItem.ItemID = ItemActor->GetItemID(); // 예제: ItemActor에 GetItemID() 메서드가 있어야 합니다.
        NewItem.Quantity = ItemActor->GetQuantity(); // 예제: ItemActor에 GetQuantity() 메서드가 있어야 합니다.

        // 아이템을 인벤토리에 추가합니다.
        PickupItem(NewItem);
        
        // 아이템 액터를 월드에서 제거합니다.
        ItemActor->Destroy();
    }
}

4. 월드 스폰 시스템

월드 스폰 시스템은 특정 위치에 아이템을 스폰하는 기능을 구현합니다. 이를 위해 스폰 포인트 액터와 스폰 데이터 테이블을 사용합니다. 스폰하고자 하는 액터의 이름, ID, 사용, 설명, 위치 등이 데이터 테이블에 정의되어 있습니다.

스폰하고자 하는 위치

스폰 포인트 액터 정의

#pragma once

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

UCLASS()
class MYGAME_API ASpawnPointActor : public AActor
{
    GENERATED_BODY()
    
public:
    ASpawnPointActor();

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Spawn")
    FName SpawnID;
};

스폰 데이터 테이블 정의

#pragma once

#include "CoreMinimal.h"
#include "Engine/DataTable.h"
#include "SpawnData.generated.h"

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

    UPROPERTY(EditAnywhere, Blueprint

ReadWrite, Category = "Spawn Data")
    FString ItemID;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Spawn Data")
    TSoftObjectPtr<AActor> SpawnPointActor;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Spawn Data")
    TSubclassOf<AActor> ItemClass;
};

스폰 매니저 클래스

#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "SpawnData.h"
#include "SpawnManager.generated.h"

UCLASS()
class MYGAME_API ASpawnManager : public AActor
{
    GENERATED_BODY()
    
public:
    ASpawnManager();

protected:
    virtual void BeginPlay() override;

public:
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Spawn")
    UDataTable* SpawnDataTable;

    void SpawnItemsFromTable();
};

// SpawnManager.cpp

#include "SpawnManager.h"
#include "Engine/World.h"
#include "Kismet/GameplayStatics.h"

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

    // 데이터 테이블 로드
    static ConstructorHelpers::FObjectFinder<UDataTable> SpawnDataObject(TEXT("DataTable'/Game/Data/SpawnDataTable.SpawnDataTable'"));
    if (SpawnDataObject.Succeeded())
    {
        SpawnDataTable = SpawnDataObject.Object;
    }
}

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

    // 스폰 아이템 초기화
    SpawnItemsFromTable();
}

void ASpawnManager::SpawnItemsFromTable()
{
    if (!SpawnDataTable) return;

    const FString ContextString(TEXT("SpawnDataTableContext"));
    TArray<FSpawnData*> AllRows;
    SpawnDataTable->GetAllRows(ContextString, AllRows);

    for (FSpawnData* Row : AllRows)
    {
        if (Row && Row->SpawnPointActor.IsValid())
        {
            AActor* SpawnPoint = Row->SpawnPointActor.Get();
            if (SpawnPoint)
            {
                FVector SpawnLocation = SpawnPoint->GetActorLocation();
                FRotator SpawnRotation = SpawnPoint->GetActorRotation();
                GetWorld()->SpawnActor<AActor>(Row->ItemClass, SpawnLocation, SpawnRotation);
            }
        }
    }
}

5. 스폰 데이터 테이블 예시

데이터 테이블은 Unreal Engine 에디터에서 FSpawnData 구조체를 기반으로 생성할 수 있습니다. 데이터 테이블은 스폰할 아이템과 스폰 위치를 정의하는 데 사용됩니다.

스폰 데이터 테이블 예시

결론

이번 작업을 통해 아이템 관리 시스템과 월드 스폰 시스템을 구현하였습니다. 데이터 테이블을 활용하여 아이템과 스폰 정보를 효율적으로 관리하고, 이를 바탕으로 인벤토리 시스템과 스폰 시스템을 구현하였습니다. 이를 통해 게임 내 아이템 관리 및 스폰 작업이 더욱 간편하고 체계적으로 이루어질 수 있게 되었습니다.

현재는 아이템 관리와 스폰 시스템을 기본적으로 구현하였으며, 다음 작업은 아이템 상호작용 기능을 추가하고, 다양한 아이템 특성을 관리하는 방법을 다룰 예정입니다. 그리고 작업을 하면서 들었던 생각이 많은데, 만약 아이템의 스폰 위치를 랜덤하게 바꾸고 싶다면? 런타임 중에 DataTable의 변경을 감지하고 Mapping을 시키려고 한다면? NPC의 대사를 저장하려고 한다면? 등의 많은 고민이 떠올랐는데 앞으로 해결해나가려고 합니다.

참고 자료


이 포스팅을 통해 여러분도 게임 내 아이템 관리와 스폰 시스템을 구현해보세요! 질문이나 피드백은 댓글로 남겨주세요. 도움이 되셨으면 좋겠네요. 감사합니다!

profile
Unreal Engine 클라이언트 개발자

0개의 댓글