레벨을 섹션이라는 단위로 나누고, 하나의 섹션을 클리어 하면 새로운 섹션이 등장하는 무한 맵 스테이지 제작하기


섹션 액터가 해야 할일

  • 섹션의 배경과 네방향으로 캐릭터 입장을 통제하는 문 제공
  • 플레이어가 섹션에 진입하면 모든 문을 닫는다
  • 문을 닫고 일정 시간 후에 섹션 중앙에서 NPC를 생성한다
  • 문을 닫고 일정시간 후에 아이템 상자를 섹션 내 랜덤한 위치에 생성한다
  • 생성한 NPC가 죽으면 모든 문을 개방한다
  • 통과한 문으로 이어지는 새로운 섹션을 생성한다

섹션 액터 제작을 위해 Actor를 부모클래스로 하는 ABSection이라는 클래스를 KGame모듈에 생성한다.

//h
#include "KGame.h"
#include "GameFramework/Actor.h"
#include "ABSection.generated.h"

UCLASS()
class KGAME_API AABSection : public AActor
{
    GENERATED_BODY()
public: 
    AABSection();

protected:
    virtual void BeginPlay() override;

public: 
    virtual void Tick(float DeltaTime) override;

private:
    UPROPERTY(VisibleAnywhere,Category=Mesh,Meta=(AllowPrtivateAccess=true))
        UStaticMeshComponent* Mesh;
};

//cpp
#include "ABSection.h"

// Sets default values
AABSection::AABSection()
{
    // Set this actor to call Tick() every frame.  You can turn this off to improve performance if you don't need it.
    PrimaryActorTick.bCanEverTick = false;


    Mesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("MESH"));
    RootComponent = Mesh;

    FString AssetPath = TEXT("/Game/Book/StaticMesh/SM_SQUARE.SM_SQUARE");
    static ConstructorHelpers::FObjectFinder<UStaticMesh> SM_SQUARE(*AssetPath);
    if (SM_SQUARE.Succeeded())
    {
        Mesh->SetStaticMesh(SM_SQUARE.Object);
    }
    else
    {
        ABLOG(Error, TEXT("Failed to load staticMesh : %s"), *AssetPath);
    }
}

// Called when the game starts or when spawned
void AABSection::BeginPlay()
{
    Super::BeginPlay();

}

// Called every frame
void AABSection::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);

}

해당 스태틱 메시에 각 방향별로 출입문과 섹션을 이어붙일 수 있게 여덟 개의 소켓이 부착돼 있다.
배경의 각 출입구에 철문을 부착한다. 철문마다 스태틱 메시 컴포넌트를 제작하고 이를 소켓에 부착한다. 제공하는 철문애셋은 부착되는 최종위치로 부터 Y축으로 -80.5 이동ㅇ한 지점
소켓목록을 제작하고 이를 사용해 철문을 각각 부착, 동일한 기능이므로 TArray로 묶어 관리

 

//h
UCLASS()
class KGAME_API AABSection : public AActor
{
    UPROPERTY(VisibleAnywhere,Category=Mesh,Meta=(AllowPrtivateAccess=true))
        UStaticMeshComponent* Mesh;
};
//cpp
AABSection::AABSection()
{
    FString GateAssetPath = TEXT("/Game/Book/StaticMesh/SM_GATE.SM_GATE");
    static ConstructorHelpers::FObjectFinder<UStaticMesh> SM_GATE(*GateAssetPath);
    if (!SM_GATE.Succeeded())
    {
        ABLOG(Error, TEXT("Failed to load staticMesh : %s"), *GateAssetPath);
    }

    static FName GateSockets[] = {
        {TEXT("+XGate")},
        {TEXT("-XGate")},
        {TEXT("+YGate")},
        {TEXT("-YGate")}
    };

    for (FName GateSocket : GateSockets)
    {
        ABCHECK(Mesh->DoesSocketExist(GateSocket));
        UStaticMeshComponent* NewGate = CreateDefaultSubobject<UStaticMeshComponent>(*GateSocket.ToString());
        NewGate->SetStaticMesh(SM_GATE.Object);
        NewGate->SetupAttachment(RootComponent, GateSocket);
        NewGate->SetRelativeLocation(FVector(0.0f, -80.5f, 0.0f));
        GeteMeshes.Add(NewGate);
    }

이번에는 ABCharacter만을 감지하는 ABTrigger라는 이름의 특별한 콜리전 프리셋을 추가한다.
이 콜리전 프리셋은 플레이어의 입장을 감지하고 섹션을 클리어 한 후 출구를 선택할 떄 사용한다.

해당 프리셋을 사용하는 Box 컴포넌트를 생성하고 섹션의 중앙과 각 철문 영역에 부착한다.

//h
class KGAME_API AABSection : public AActor
{
    UPROPERTY(VisibleAnywhere, Category = Trigger, Meta = (AllowPrtivateAccess = true))
        TArray<UBoxComponent*> GateTriggers;

    UPROPERTY(VisibleAnywhere, Category = Trigger, Meta = (AllowPrtivateAccess = true))
        UBoxComponent* Trigger;
};
}

//cpp
AABSection::AABSection()
{
    PrimaryActorTick.bCanEverTick = false;


    Mesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("MESH"));
    RootComponent = Mesh;

    FString AssetPath = TEXT("/Game/Book/StaticMesh/SM_SQUARE.SM_SQUARE");
    static ConstructorHelpers::FObjectFinder<UStaticMesh> SM_SQUARE(*AssetPath);
    if (SM_SQUARE.Succeeded())
    {
        Mesh->SetStaticMesh(SM_SQUARE.Object);
    }
    else
    {
        ABLOG(Error, TEXT("Failed to load staticMesh : %s"), *AssetPath);
    }

    Trigger = CreateDefaultSubobject<UBoxComponent>(TEXT("TRIGGER"));
    Trigger->SetBoxExtent(FVector(775.0f, 775.0f, 300.0f));
    Trigger->SetupAttachment(RootComponent);
    Trigger->SetRelativeLocation(FVector(0.0f, 0.0f, 250.0f));
    Trigger->SetCollisionProfileName(TEXT("ABTrigger"));

    FString GateAssetPath = TEXT("/Game/Book/StaticMesh/SM_GATE.SM_GATE");
    static ConstructorHelpers::FObjectFinder<UStaticMesh> SM_GATE(*GateAssetPath);
    if (!SM_GATE.Succeeded())
    {
        ABLOG(Error, TEXT("Failed to load staticMesh : %s"), *GateAssetPath);
    }


    static FName GateSockets[] = {
        {TEXT("+XGate")},
        {TEXT("-XGate")},
        {TEXT("+YGate")},
        {TEXT("-YGate")}
    };

    for (FName GateSocket : GateSockets)
    {
.....

        UBoxComponent* NewGateTrigger = CreateDefaultSubobject<UBoxComponent>(*GateSocket.ToString().Append(TEXT("Trigger")));

        NewGateTrigger->SetBoxExtent(FVector(100.0f,100.0f,300.0f));
        NewGateTrigger->SetupAttachment(RootComponent,GateSocket);
        NewGateTrigger->SetRelativeLocation(FVector(70.0f,0.0f,250.0f));
        NewGateTrigger->SetCollisionProfileName(TEXT("ABTrigger"));
        GateTriggers.Add(NewGateTrigger);
    }

}

액터 설정을 완료하고 액터의 로직을 스테이트 머신으로 설계한다
다음과 같은 스테이트들을 가진다.
열거형을 사용하여 구현

  • 준비스테이트 : 액터의 시작 스테이트, 문을 열어놓고 대기하다가 중앙의 박스트리거로 플레이어의 진입을 감지하면 전투스테이트로 이동한다.
  • 전투스테이트 : 문을 닫고 일정 시간이 지마녀 NPC를 소환한다. 그리고 일정시간이 지나면 랜덤한 위치에 아이템 상자도 생성한다. NPC가 죽으면 완료 스테이트로 이동한다.
  • 완료스테이트 : 닫힌 문을 연다. 각 문마다 배치한 트리거 게이트로 플레이를 감지하면 이동한 문의 방향으로 새로운 섹션을 소환한다
  • bNobattle : 에디터에 전투가 없으므로 true로 설정한다
//h
class KGAME_API AABSection : public AActor
{
private:
    enum class ESectionState : uint8
    {
        READY = 0,
        BATTLE,
        COMPLETE
    };

    void SetState(ESectionState NewState);
    ESectionState CurrentState = ESectionState::READY;

    void OperateGates(bool bOpen = true);

private:
    UPROPERTY(EditAnywhere, Category = State, Meta = (AllowPrtivateAccess = true))
        bool bNoBattle;
};

//cpp
AABSection::AABSection()
{
..........
    bNoBattle = false;
}


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

    SetState(bNoBattle ? ESectionState::COMPLETE : ESectionState::READY);
}



void AABSection::SetState(ESectionState NewState)
{
    switch (NewState)
    {
    case ESectionState::READY:
        Trigger->SetCollisionProfileName(TEXT("ABTrigger"));
        for (UBoxComponent* GateTrigger : GateTriggers)
        {
            GateTrigger->SetCollisionProfileName(TEXT("NoCollision"));
        }
        OperateGates(true);

        break;

    case ESectionState::BATTLE :
        Trigger->SetCollisionProfileName(TEXT("NoCollision"));
        for (UBoxComponent* GateTrigger : GateTriggers)
        {
            GateTrigger->SetCollisionProfileName(TEXT("NoCollision"));
        }
        OperateGates(false);

        break;

    case ESectionState::COMPLETE:
        Trigger->SetCollisionProfileName(TEXT("NoCollision"));
        for (UBoxComponent* GateTrigger : GateTriggers)
        {
            GateTrigger->SetCollisionProfileName(TEXT("ABTrigger"));
        }
        OperateGates(true);

        break;
    default:
        break;
    }


}

void AABSection::OperateGates(bool bOpen)
{
    for (UStaticMeshComponent* Gate : GeteMeshes)
    {
        Gate->SetRelativeRotation(bOpen ? FRotator(0.0f,-90.0f,0.0f) : FRotator::ZeroRotator);
    }
}

터에는 어디터와 연동되는 OnConstruction이라는 특별한 함수가 있다.
에디터 작업에서 선택한 액터의 속성이나 트랜스폼 정보가 변경 될떄 이 OnConstruction 함수가 실행된다.

 

//h
class KGAME_API AABSection : public AActor
{
public: 
    AABSection();
    virtual void OnConstruction(const FTransform& Transform) override;

//cpp
void AABSection::OnConstruction(const FTransform& Transform)
{
    Super::OnConstruction(Transform);
    SetState(bNoBattle ? ESectionState::COMPLETE : ESectionState::READY);
}

액터의 완료 스테이트에서는 각 철문에 있는 트리거가 활성화 되고, 플레이어가 감지되면, 해당 철문의 방향으로 새로운 섹션액터를 생성하는 로직이 들어가야 한다.
그런데 게임의 진행 상황에 따라 해당 위치에 이미 섹션 액터가 생성돼 있을 수 있으므로 이를 미리 확인하는 로직이 필요하다. 물리엔진 기능을 사용해 해당 위치에 액터가 있다면 생성을 건너뛴다.
새롭게 생성된 섹션 액터를 READY 스테이트에서 부터 시작하는데, 가운데 트리거 여영ㄱ을 활성화해서 플레이어가 들어오는지 감지해야 한다. 플레이어를 감지하면 바로 BATTLE 스텡트로 전환하고 플레이어가 빠져나가지 못하게 문을 닫는다.
박스컴포넌트 OnComponentBeginOveralp 델리게이트에 바인딩시클 함수를 생성하고 이를 연결한다. 해당 델리게이트 다이나믹 델리게이트이므로 함수 선언에 UFUNCTION을 지정한다.
총 4개의 문에 각각 4개의 박스 컴포넌트가 있지만, 이들 기능은 동일하다. 그래서 모든 문에 설치된 박스컴포넌트의 델리게이트에 하나의 멤버 함수를 연결한다. 이때 감지된 박스 컴포넌트가 어떤 문에 있는 컴포넌트인지 구분 할 수 있도록 컴포넌트에 소켓이름으로 태그(Tag)를 설정하고, 이를 사용해 해당 방향에 띄울 다음 섹션 액터까지 생성하는 기능을 구현한다.

/h
UCLASS()
class KGAME_API AABSection : public AActor
{
    GENERATED_BODY()
public:
    AABSection();
    virtual void OnConstruction(const FTransform& Transform) override;
protected:
private:
    UFUNCTION()
        void OnTriggerBeginOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult &SweepResult);

    UFUNCTION()
        void OnGateTriggerBeginOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult &SweepResult);

//cpp
AABSection::AABSection()
{

    Trigger->SetCollisionProfileName(TEXT("ABTrigger"));

    Trigger->OnComponentBeginOverlap.AddDynamic(this, &AABSection::OnTriggerBeginOverlap);



    for (FName GateSocket : GateSockets)
    {
        ABCHECK(Mesh->DoesSocketExist(GateSocket));
        UStaticMeshComponent* NewGate = CreateDefaultSubobject<UStaticMeshComponent>(*GateSocket.ToString());
        NewGate->SetStaticMesh(SM_GATE.Object);
        NewGate->SetupAttachment(RootComponent, GateSocket);
        NewGate->SetRelativeLocation(FVector(0.0f, -80.5f, 0.0f));
        GateMeshes.Add(NewGate);

        UBoxComponent* NewGateTrigger = CreateDefaultSubobject<UBoxComponent>(*GateSocket.ToString().Append(TEXT("Trigger")));
        NewGateTrigger->SetBoxExtent(FVector(100.0f, 100.0f, 300.0f));
        NewGateTrigger->SetupAttachment(RootComponent, GateSocket);
        NewGateTrigger->SetRelativeLocation(FVector(70.0f, 0.0f, 250.0f));
        NewGateTrigger->SetCollisionProfileName(TEXT("ABTrigger"));
        GateTriggers.Add(NewGateTrigger);

        NewGateTrigger->OnComponentBeginOverlap.AddDynamic(this, &AABSection::OnGateTriggerBeginOverlap);
        NewGateTrigger->ComponentTags.Add(GateSocket);
    }

    bNoBattle = false;
}



void AABSection::OnTriggerBeginOverlap(UPrimitiveComponent * OverlappedComponent, AActor * OtherActor, UPrimitiveComponent * OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult & SweepResult)
{
    if (CurrentState == ESectionState::READY)
    {
        SetState(ESectionState::BATTLE);
    }
}

void AABSection::OnGateTriggerBeginOverlap(UPrimitiveComponent * OverlappedComponent, AActor * OtherActor, UPrimitiveComponent * OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult & SweepResult)
{
    ABCHECK(OverlappedComponent->ComponentTags.Num() == 1);
    FName ComponentTag = OverlappedComponent->ComponentTags[0];
    FName SocketName = FName(*ComponentTag.ToString().Left(2));
    if (!Mesh->DoesSocketExist(SocketName))
        return;

    FVector NewLocation = Mesh->GetSocketLocation(SocketName);

    TArray<FOverlapResult> OverlapResults;
    FCollisionQueryParams CollisionQueryParam(NAME_None, false, this);
    FCollisionObjectQueryParams ObjectQueryParam(FCollisionObjectQueryParams::InitType::AllObjects);
    bool bResult = GetWorld()->OverlapMultiByObjectType(
        OverlapResults,
        NewLocation,
        FQuat::Identity,
        ObjectQueryParam,
        FCollisionShape::MakeSphere(775.0f),
        CollisionQueryParam
    );

    if (!bResult)
    {
        auto NewSection = GetWorld()->SpawnActor<AABSection>(NewLocation, FRotator::ZeroRotator);
    }
    else
    {
        ABLOG(Warning, TEXT("New section area is not empty."));
    }
}

+ Recent posts