무한 맵의 제작
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 클래스 생성
// 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.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);
}
// 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 |
댓글()