무한 맵의 제작

Unreal 이론|2024. 10. 31. 18:14

[이득우의 언리얼 프로그래밍 Part2 수업의 정리]

 

스테이지 기믹 기획

  • 스테이지는 플레이어와 NPC가 1:1로 겨루는 장소
  • 스테이지는 총 4개의 상태를 가지고 있으며 순서대로 진행
    • Ready: 플레이어 입장 처리
    • Fight: 플레이어와 NPC 대전
    • Reward: 플레이어가 보상 선택
    • Next: 다음 스테이지로 이동 처리
  • 무한히 순환하는 구조로 설계

스테이지 준비 단계

  • 스테이지 중앙에 위치한 트리거 볼륨 준비
  • 플레이어가 트리거 볼륨에 진입하면 대전 단계로 이동

스테이지 대전 단계

  • 플레이어가 못 나가게 스테이지의 모든 문 닫고 대전할 NPC 스폰
  • NPC 없어지면 보상 단계로 이동

스테이지 보상 선택 단계

  • 정해진 위치의 4개의 상자에서 아이템 랜덤하게 생성
  • 상자 중 하나를 선택하면 다음 스테이지 단계로 이동

다음 스테이지 선택 단계

  • 스테이지 문 개방
  • 문에 설치된 트리거 볼륨 활용해 통과하는 문에 새로운 스테이지 스폰

스테이지 기믹의 설계와 구현

  • 스테이지에 설치한 트리거 볼륨의 감지 처리
  • 각 문에 설치한 4개의 트리거 볼륨의 감지 처리
  • 상태별로 설정할 문의 회전 설정
  • 대전할 NPC의 스폰 기능
  • 아이템 상자의 스폰 기능
  • NPC의 죽음 감지 기능
  • 아이템 상자의 오버랩 감지

 

에셋 매니저

  • 언리얼 엔진이 제공하는 에셋을 관리하는 싱글톤 클래스
  • 엔진이 초기화될 때 제공되며, 에셋 정보를 요청해 받을 수 있음
  • PrimaryAssetId를 사용해 프로젝트 내 에셋의 주소를 얻어로 수 있음
  • PrimaryAssetId는 태그와 이름의 두가지 키 조합으로 구성되어 있음
  • 특정 태그를 가진 모든 에셋 목록을 가져올 수 있음

 

랜덤 보상 설정

  • 아이템 데이터에 ABItemData라는 에셋 태그 설정
  • 프로젝트 설정에서 해당 에셋들이 담긴 폴더를 지정
  • 전체 에셋 목록 중에서 하나를 랜덤으로 선택하고 이를 로딩해 보상으로 할당

위와 같은 로직의 구현 위해 AActor를 상속받은 ABStageGimmick 클래스 생성

스테이지의 Static Mesh를 열어보면 동서남북 게이트 위치에 소켓 확인 가능

// ABStageGimmick.h

// Fill out your copyright notice in the Description page of Project Settings.

#pragma once

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

UCLASS()
class ARENABATTLE_API AABStageGimmick : public AActor
{
	GENERATED_BODY()
	
public:	
	// Sets default values for this actor's properties
	AABStageGimmick();

	// Stage Section
protected:
	UPROPERTY(VisibleAnywhere, Category = Stage, Meta = (AllowPrivateAccess = "true"))
	TObjectPtr<class UStaticMeshComponent> Stage;

	UPROPERTY(VisibleAnywhere, Category = Stage, Meta = (AllowPrivateAccess = "true"))
	TObjectPtr<class UBoxComponent> StageTrigger;

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

	// Gate Section
protected:
	UPROPERTY(VisibleAnywhere, Category = Gate, Meta = (AllowPrivateAccess = "true"))
	TMap<FName, TObjectPtr<class UStaticMeshComponent>> Gates;

	UPROPERTY(VisibleAnywhere, Category = Gate, Meta = (AllowPrivateAccess = "true"))
	TArray<TObjectPtr<class UBoxComponent>> GateTriggers;

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



// ABStageGimmick.cpp

// Fill out your copyright notice in the Description page of Project Settings.


#include "Gimmick/ABStageGimmick.h"
#include "Components/StaticMeshComponent.h"
#include "Components/BoxComponent.h"
#include "Physics/ABCollision.h"

// Sets default values
AABStageGimmick::AABStageGimmick()
{
 	// Stage Section
    Stage = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("Stage"));
    RootComponent = Stage;

    static ConstructorHelpers::FObjectFinder<UStaticMesh> StageMeshRef(TEXT("/Script/Engine.StaticMesh'/Game/ArenaBattle/Environment/Stages/SM_SQUARE.SM_SQUARE'"));
    if (StageMeshRef.Object) {
        Stage->SetStaticMesh(StageMeshRef.Object);
    }

    StageTrigger = CreateDefaultSubobject<UBoxComponent>(TEXT("StageTrigger"));
    StageTrigger->SetBoxExtent(FVector(775.0f, 775.0f, 300.0f));
    StageTrigger->SetupAttachment(Stage);
    StageTrigger->SetRelativeLocation(FVector(0.0f, 0.0f, 250.0f));
    StageTrigger->SetCollisionProfileName(CPROFILE_ABTRIGGER);
    StageTrigger->OnComponentBeginOverlap.AddDynamic(this, &AABStageGimmick::OnStageTriggerBeginOverlap);

    // Gate Section
    static FName GateSockets[] = { TEXT("+XGate"), TEXT("-XGate"), TEXT("+YGate"), TEXT("-YGate") };
    static ConstructorHelpers::FObjectFinder<UStaticMesh> GateMeshRef(TEXT("/Script/Engine.StaticMesh'/Game/ArenaBattle/Environment/Props/SM_GATE.SM_GATE'"));
    for (FName GateSocket : GateSockets) {
        UStaticMeshComponent* Gate = CreateDefaultSubobject<UStaticMeshComponent>(GateSocket);
        Gate->SetStaticMesh(GateMeshRef.Object);
        Gate->SetupAttachment(Stage, GateSocket);
        Gate->SetRelativeLocation(FVector(0.0f, -80.5f, 0.0f));
        Gate->SetRelativeRotation(FRotator(0.0f, -90.0f, 0.0f));
        Gates.Add(GateSocket, Gate);

        FName TriggerName = *GateSocket.ToString().Append(TEXT("Trigger"));
        UBoxComponent* GateTrigger = CreateDefaultSubobject<UBoxComponent>(TriggerName);
        GateTrigger->SetBoxExtent(FVector(100.0f, 100.0f, 300.0f));
        GateTrigger->SetupAttachment(Stage, GateSocket); // 소켓을 부여해서 좀 더 편리하게 위치 조정
        GateTrigger->SetRelativeLocation(FVector(70.0f, 0.0f, 250.0f));
        GateTrigger->SetCollisionProfileName(CPROFILE_ABTRIGGER);
        GateTrigger->OnComponentBeginOverlap.AddDynamic(this, &AABStageGimmick::OnGateTriggerBeginOverlap);
        GateTrigger->ComponentTags.Add(GateSocket); // 동서남북 중 어느문으로 들어갔는지 알 수 있도록 태그값 부착
    }

}
  • Gate 부분은 4개의 문으로 구성했기에 배열로 이름 지정 및 반복문을 돌면서 동일한 4개의 문 제작

빌드 돌려서 실행한 후, ABStageGimmick 클래스를 상속받은 BP클래스 생성. 4개의 문 트리거와 스테이지 트리거가 잘 생성되있다.

 

스테이지의 상태를 보관하고, 그에따른 동작을 실행할 함수 제작

// ABStageGimmick.h

DECLARE_DELEGATE(FOnStageChangedDelegate);
USTRUCT(BlueprintType)
struct FStageChangedDelegateWrapper {
	GENERATED_BODY()
	FStageChangedDelegateWrapper(){ }
	FStageChangedDelegateWrapper(const FOnStageChangedDelegate& InDelegate) : StageDelegate(InDelegate) {}
	FOnStageChangedDelegate StageDelegate;
};

UENUM(BlueprintType)
enum class EStageState : uint8 {
	READY = 0,
	FIGHT,
	REWARD,
	NEXT
};

	// State Section
protected:
	UPROPERTY(EditAnywhere, Category = Stage, Meta = (AllowPrivateAccess = "true"))
	EStageState CurrentState;

	void SetState(EStageState InNewState); // 상태 변경은 직접 접근 대신 SetState 함수로만 이뤄지도록! (생성자에서만 직접 접근해 초기화)

	UPROPERTY()
	TMap<EStageState, FStageChangedDelegateWrapper> StateChangeActions;

	void SetReady();
	void SetFight();
	void SetChooseReward();
	void SetChooseNext();
    
// ABStageGimmick.cpp
    
AABStageGimmick::AABStageGimmick()
{
    // State Section
    CurrentState = EStageState::READY;
    StateChangeActions.Add(EStageState::READY, FStageChangedDelegateWrapper(FOnStageChangedDelegate::CreateUObject(this, &AABStageGimmick::SetReady)));
    StateChangeActions.Add(EStageState::FIGHT, FStageChangedDelegateWrapper(FOnStageChangedDelegate::CreateUObject(this, &AABStageGimmick::SetFight)));
    StateChangeActions.Add(EStageState::REWARD, FStageChangedDelegateWrapper(FOnStageChangedDelegate::CreateUObject(this, &AABStageGimmick::SetChooseReward)));
    StateChangeActions.Add(EStageState::NEXT, FStageChangedDelegateWrapper(FOnStageChangedDelegate::CreateUObject(this, &AABStageGimmick::SetChooseNext)));
}

void AABStageGimmick::SetState(EStageState InNewState)
{
    // switch문은 state가 늘어나면 복잡해진다.
    // Delegate Array로 함수 포인터 사용해 구현

    CurrentState = InNewState;

    if (StateChangeActions.Contains(InNewState)) {
        StateChangeActions[CurrentState].StageDelegate.ExecuteIfBound();
    }

}
  • State 상태를 지정할 열거형 선언, switch문을 통해 4가지 상태의 분기에 따른 동작을 제어하는 로직을 구성하는 대신, 델리게이트 배열로 함수 포인터를 사용해 가독성을 높인다.
// ABStageGimmick.cpp

void AABStageGimmick::OpenAllGates()
{
    for (auto Gate : Gates) {
        (Gate.Value)->SetRelativeRotation(FRotator(0.0f, -90.0f, 0.0f));
    }
}

void AABStageGimmick::CloseAllGates()
{
    for (auto Gate : Gates) {
        (Gate.Value)->SetRelativeRotation(FRotator::ZeroRotator);
    }
}

void AABStageGimmick::SetState(EStageState InNewState)
{
    // switch문은 state가 늘어나면 복잡해진다.
    // Delegate Array로 함수 포인터 사용해 구현

    CurrentState = InNewState;

    if (StateChangeActions.Contains(InNewState)) {
        StateChangeActions[CurrentState].StageDelegate.ExecuteIfBound();
    }

}

void AABStageGimmick::SetReady()
{
    // 스테이지 트리거만 활성화
    StageTrigger->SetCollisionProfileName(CPROFILE_ABTRIGGER);
    for (auto GateTrigger : GateTriggers) {
        GateTrigger->SetCollisionProfileName(TEXT("NoCollision"));
    }

    CloseAllGates();
}

void AABStageGimmick::SetFight()
{
    // 모든 트리거 비활성화
    StageTrigger->SetCollisionProfileName(TEXT("NoCollision"));
    for (auto GateTrigger : GateTriggers) {
        GateTrigger->SetCollisionProfileName(TEXT("NoCollision"));
    }

    CloseAllGates();
}

void AABStageGimmick::SetChooseReward()
{
    // 모든 트리거 비활성화
    StageTrigger->SetCollisionProfileName(TEXT("NoCollision"));
    for (auto GateTrigger : GateTriggers) {
        GateTrigger->SetCollisionProfileName(TEXT("NoCollision"));
    }

    CloseAllGates();
}

void AABStageGimmick::SetChooseNext()
{
    // 문 트리거만 활성화
    StageTrigger->SetCollisionProfileName(TEXT("NoCollision"));
    for (auto GateTrigger : GateTriggers) {
        GateTrigger->SetCollisionProfileName(CPROFILE_ABTRIGGER);
    }

    OpenAllGates();
}
  • 문의 열고닫힘을 제어하는 함수 추가, 4가지 상태에 따른 트리거 활성 및 비활성화 로직 추가

 

이렇게 함수를 구현해놓고, 에디터에서 테스트 할 때 AB_StageGimmick의 Detail 패널에서 값을 변경한다고 해서 바로 시뮬레이션 할 수는 없다.

OnConstruction 함수를 상속받아 구현함으로써 State값 변화에 따른 Trigger의 발동을 쉽게 테스트 가능

void AABStageGimmick::OnConstruction(const FTransform& Transform)
{
    // Transform뿐만 아니라 속성이 변경될때 호출됨
    Super::OnConstruction(Transform);

    SetState(CurrentState);
}

문이 열리는 유일한 상태인 NEXT 입력하자 문이 열린다

// ABStageGimmick.h
    
    // Fight Section
protected:
	// 언리얼에서 제공하는 TSubclassOf 템플릿 사용. 지정한 클래스로부터 상속받은 클래스 목록만 표시되도록 한정해서 지정 가능
	// NPC 구현시 블루프린트로 확장해 다양한 캐릭터 만들 때, 모든 액터의 목록 조사하지 않도록 클래스 정보 한정시킨다.
	UPROPERTY(EditAnywhere, Category = Fight, Meta = (AllowPrivateAccess = "true"))
	TSubclassOf<class AABCharacterNonPlayer> OpponentClass;

	UPROPERTY(EditAnywhere, Category = Fight, Meta = (AllowPrivateAccess = "true"))
	float OpponentSpawnTime; // NPC가 바로 스폰되지 않도록 Delay 걸어줄 변수

	// NPC 죽었을 때 보상단계로 진입시키기 위한 함수
	UFUNCTION()
	void OnOpponentDestroyed(AActor* DestroydActor);

	FTimerHandle OpponentTimerHandle;
	void OnOpponentSpawn();


// ABStageGimmick.cpp

void AABStageGimmick::OnStageTriggerBeginOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
    SetState(EStageState::FIGHT);
}

void AABStageGimmick::OnGateTriggerBeginOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
    check(OverlappedComponent->ComponentTags.Num() == 1); // GateSockets 이름으로 달아준 OverlappedComponent의 태그 검증
    FName ComponentTag = OverlappedComponent->ComponentTags[0];
    FName SocketName = FName(*ComponentTag.ToString().Left(2)); // 태그를 받아서 왼쪽 두개만 잘라낸다 (+X, +Y 등)
    check(Stage->DoesSocketExist(SocketName));

    FVector NewLocation = Stage->GetSocketLocation(SocketName); // 소켓 위치를 태그 이름을 통해 얻음
    TArray<FOverlapResult> OverlapResults;
    FCollisionQueryParams CollisionQueryParam(SCENE_QUERY_STAT(GateTrigger), false, this); // this -> 자신을 제외한 상태에서 검사
    // OverlapMultiByObjectType 함수를 사용해 해당 위치에 무언가 배치되어 있는지 검사
    bool bResult = GetWorld()->OverlapMultiByObjectType(
        OverlapResults,
        NewLocation,
        FQuat::Identity,
        FCollisionObjectQueryParams::InitType::AllStaticObjects, // 모든 스태틱 오브젝트 타입에 대해 
        FCollisionShape::MakeSphere(775.0f), // 해당 위치에 큰 구체 생성
        CollisionQueryParam
    );

    if (!bResult) { // 검사한 위치에 아무것도 없을 시
        GetWorld()->SpawnActor<AABStageGimmick>(NewLocation, FRotator::ZeroRotator);
    }
}

void AABStageGimmick::OnOpponentDestroyed(AActor* DestroydActor)
{
    SetState(EStageState::REWARD);
}

void AABStageGimmick::OnOpponentSpawn()
{
    const FVector SpawnLocation = GetActorLocation() + FVector::UpVector * 88.0f;
    AActor* OpponentActor = GetWorld()->SpawnActor(OpponentClass, &SpawnLocation, &FRotator::ZeroRotator);
    AABCharacterNonPlayer* ABOpponentCharacter = Cast<AABCharacterNonPlayer>(OpponentActor);
    if (ABOpponentCharacter) {
        ABOpponentCharacter->OnDestroyed.AddDynamic(this, &AABStageGimmick::OnOpponentDestroyed);
    }
}

 

  • 플레이어와 싸울 NPC를 ABCharacterNonPlayer를 상속받아 구현, NPC의 스폰 로직 및 전투 후 State 변경 함수 구현
  • Gate 트리거 함수를 작성. 문을 통과하는 시점에서 전방 구역을 검사해 아무것도 배치되있지 않다면 맵을 생성
// ABStageGimmick.cpp

AABStageGimmick::AABStageGimmick()
{
 	// Fight Section
    OpponentSpawnTime = 2.0f;
    OpponentClass = AABCharacterNonPlayer::StaticClass();
}

void AABStageGimmick::SetFight()
{
    // 모든 트리거 비활성화
    StageTrigger->SetCollisionProfileName(TEXT("NoCollision"));
    for (auto GateTrigger : GateTriggers) {
        GateTrigger->SetCollisionProfileName(TEXT("NoCollision"));
    }

    CloseAllGates();

    GetWorld()->GetTimerManager().SetTimer(OpponentTimerHandle, this, &AABStageGimmick::OnOpponentSpawn, OpponentSpawnTime, false);
}

 

  • Stage에 들어와 Trigger가 발동되면 2초 후 NPC가 스폰되도록 코드 수정
  • 소환된 NPC가 죽고 Destroy 함수가 발동된 후 Gimmick의 State를 보면 REWARD로 변경된 것 확인 가능

 

// ABStageGimmick.h

// Reward Section
protected:
	UPROPERTY(VisibleAnywhere, Category = Reward, Meta = (AllowPrivateAccess = "true"))
	TSubclassOf<class AABItemBox> RewardBoxClass;

	UPROPERTY(VisibleAnywhere, Category = Reward, Meta = (AllowPrivateAccess = "true"))
	TArray<TWeakObjectPtr<class AABItemBox>> RewardBoxes;

	TMap<FName, FVector> RewardBoxLocations;

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

	void SpawnRewardBoxes();
    
// ABStageGimmick.cpp

void AABStageGimmick::OnRewardTriggerBeginOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
    for (const auto& RewardBox : RewardBoxes) {
        if (RewardBox.IsValid()) {
            AABItemBox* ValidItemBox = RewardBox.Get();
            AActor* OverlappedBox = OverlappedComponent->GetOwner();
            if (OverlappedBox != ValidItemBox) {
                ValidItemBox->Destroy();
            }
        }
    }

    SetState(EStageState::NEXT);
}

void AABStageGimmick::SpawnRewardBoxes()
{
    for (const auto& RewardBoxLocation : RewardBoxLocations) {
        FVector WorldSpawnLocation = GetActorLocation() + RewardBoxLocation.Value + FVector(0.0f, 0.0f, 30.0f);
        AActor* ItemActor = GetWorld()->SpawnActor(RewardBoxClass, &WorldSpawnLocation, &FRotator::ZeroRotator);
        AABItemBox* RewardBoxActor = Cast<AABItemBox>(ItemActor);
        if (RewardBoxActor) {
            RewardBoxActor->Tags.Add(RewardBoxLocation.Key);
            RewardBoxActor->GetTrigger()->OnComponentBeginOverlap.AddDynamic(this, &AABStageGimmick::OnRewardTriggerBeginOverlap);
            RewardBoxes.Add(RewardBoxActor);
        }
    }
}
  • Reward 로직의 함수를 구현
  • TWeakObjectPtr 템플릿 클래스를 사용해 약참조로 RewardBoxes 선언. 액터와 무관하게 내부 로직에서 스폰하거나 파괴될 수 있기 때문에 강참조로 걸게되면 메모리에서 소멸되지 않을 수 있음.
  • 생성된 상자의 Overlap 이벤트 선언, 상자 스폰 함수 선언
// ABItemBox.h

public:
	// Sets default values for this actor's properties
	AABItemBox();

	FORCEINLINE class UBoxComponent* GetTrigger() { return Trigger; }

protected:
	UPROPERTY(VisibleAnywhere, Category = Box)
	TObjectPtr<class UBoxComponent> Trigger; // Loot Component
  • ItemBox의 Trigger는 protected로 선언되있기 때문에 외부에서 접근하도록 Inline 함수 선언

 


상자에 보상 추가

  • 에셋 매니저라는 싱글톤 클래스를 사용해 ABItemData 관리
  • 이 에셋 매니저에서 모든 에셋 불러오도록 설정

 

// ABItemData.h

public:
	FPrimaryAssetId GetPrimaryAssetId() const override {
		// 생성될 에셋의 아이디 직접 지정
		return FPrimaryAssetId("ABItemData", GetFName());
	}
// ABItemBox.h

protected:
	// 아이템 박스가 초기화 됬을 때 에셋 매니저가 제공하고 있는 목록 살펴서 랜덤으로 하나 선택해 할당
	virtual void PostInitializeComponents() override; // 액터 세팅 마무리 시점에 호출되는 함수
    
void AABItemBox::PostInitializeComponents()
{
	// 엔진이 초기화될때 언제나 로딩된다는 보장O
	Super::PostInitializeComponents();

	UAssetManager& Manager = UAssetManager::Get();

	TArray<FPrimaryAssetId> Assets;
	Manager.GetPrimaryAssetIdList(TEXT("ABItemData"), Assets);
	ensure(0 < Assets.Num());

	int32 RandomIndex = FMath::RandRange(0, Assets.Num() - 1); // 전체 개수 중 하나 랜덤
	FSoftObjectPtr AssetPtr(Manager.GetPrimaryAssetPath(Assets[RandomIndex]));
	if (AssetPtr.IsPending()) { // 약참조로 선언했기 때문에 로딩 되있지 않다면 로딩시킨다
		AssetPtr.LoadSynchronous();
	}
	Item = Cast<UABItemData>(AssetPtr.Get());
	ensure(Item);
}
  • ABItemData.h에서 UPrimaryDataAsset의 GetPrimaryAssetId() 상속받아 구현
  • ABItemBox에 에셋 매니저에서 에셋을 가져와 랜덤으로 Item 변수에 형변환으로 집어넣어주는 로직 구현
  • 이후 ItemData들을 Reload 한번 해주고 실행해보면 랜덤하게 보상이 잘 들어가있다.

'Unreal 이론' 카테고리의 다른 글

인공지능 - 행동트리 모델의 이해  (2) 2024.11.10
게임데이터 관리  (0) 2024.11.06
아이템 시스템  (1) 2024.10.30
캐릭터 스탯과 위젯  (1) 2024.10.27
캐릭터 콤보 액션  (0) 2024.10.22

댓글()