=> 정리해보면 총 5개(메인 1개 , 문마다 1개씩)의 Trigger Volume, 3개의 Spawn(NPC, ItemBox, Stage), 문 회전, NPC 죽음, 상자와 player의 overlap 기능을 구현해보자.
Ready
: Player의 입장을 처리하는 단계
Fight
: Player와 NPC가 대전하는 단계
Reward
: Player가 보상을 선택하는 단계
Next
: 다음 Stage로 이동을 처리하는 단계
Ready
-> Fight
-> Reward
-> Next
가 무한히 순환하는 구조
// StageGimmick.h
UENUM(BlueprintType)
enum class EStageState : uint8
{
READY = 0,
FIGHT,
REWARD,
NEXT
};
이 상태는 우리가 열거형으로 선언을 한다.
중요! 게임에서 이렇게 단계의 구성은 state
로 관리를 하는데, 모든 게임에서 단계의 전환은 항상 있을 것이므로 이 방법을 잘 숙지하고 나중에 무조건 적용해보자!
// StageGimmick.h
// Stage Section
StageTrigger->SetCollisionProfileName(TEXT("RyanTrigger"));
StageTrigger->OnComponentBeginOverlap.AddDynamic(this, &ARyanStageGimmick::OnStageTriggerBeginOverlap);
이제는 많이 봐와서 익숙하겠지만 Stage에는 Pawn에 대해서만 Overlap event를 탐지하는 Collision Profile을 적용하고, Overlap event가 발생했을때는 delegate를 사용해서 등록된 함수 OnStageTriggerBeginOverlap
을 발동하게 만든다.
Gates는 TMap<FName, TObjectPtr<class UStaticMeshComponent>>
GateTrigger는 TArray<TObjectPtr<class UBoxComponent>>
Gate쪽 로직을 구현할 때에는 총 4개의 gate들과 그에 해당하는 4개의 Trigger Box를 구현해주어야 한다.
Gate의 StaticMesh에서의 Socket Manager를 살펴보면 총 8개의 socket이 존재한다. Gate가 부착될 +XGate, -XGate, +YGate, -YGate socket과 이어질 Stage가 생성될 포인트인 +X, -X, +Y, -Y socket.
void ARyanStageGimmick::OnGateTriggerBeginOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
check(OverlappedComponent->ComponentTags.Num() == 1);
FName ComponentTag = OverlappedComponent->ComponentTags[0];
FName SocketName = FName(*ComponentTag.ToString().Left(2));
check(Stage->DoesSocketExist(SocketName));
FVector NewLocation = Stage->GetSocketLocation(SocketName);
TArray<FOverlapResult> OverlapResults;
FCollisionQueryParams CollisionQueryParam(SCENE_QUERY_STAT(GateTrigger), false, this);
bool bResult = GetWorld()->OverlapMultiByObjectType(
OverlapResults,
NewLocation,
FQuat::Identity,
FCollisionObjectQueryParams::InitType::AllObjects,
FCollisionShape::MakeSphere(775.0f),
CollisionQueryParam
);
if (!bResult)
{
GetWorld()->SpawnActor<ARyanStageGimmick>(NewLocation, FRotator::ZeroRotator);
}
}
캐릭터가 다음 Stage로 가려고 할때 새로운 Gimmick Stage를 붙이는 코드이다. Gate에 존재하는 Trigger Box와 OverLap시 이에 해당하는 OverLap Delegate에 인자로 넘겨질 OnGateTriggerBeginOverlap 함수에 대한 로직을 위 코드 snippet에서 확인할 수 있다.
여기서 생성하려고 하는 위치에 이미 Stage Gimmick가 있는데 다시 생성하면 안되므로 OverlapMultiByObjectType
으로 생성 위치에 collision 검사를 하고 결과가 null인 경우에만 새로운 Stage Gimmick을 생성하도록 한다.
Switch-Case문으로 상태 변경에 대한 대응을 할 수도 있지만, 이는 코드의 가독성을 떨어뜨릴 위험이 있다. 앞 강에서 상자의 아이템 효과를 캐릭터에 적용할 때 사용했던 Delegate를 구조체에 감싸 컨테이너(이번에는 TMap!)에 저장하는 형식으로 State 전환 또한 똑같이 디자인 해보자.
Step 1
: Delegate 구조체 만들기
// StageGimmick.h
// Custom Delegate 선언
DECLARE_DELEGATE(FOnStageChangedDelegate);
// Delegate Array에 들어갈 Delegate을 wrapping하는 구조체 선언
USTRUCT(BlueprintType)
struct FStageChangedDelegateWrapper
{
GENERATED_BODY()
FStageChangedDelegateWrapper() { }
FStageChangedDelegateWrapper(const FOnStageChangedDelegate& InDelegate) : StageDelegate(InDelegate) {}
FOnStageChangedDelegate StageDelegate;
};
Step 2
: TMap에 Delegate Wrapper 추가하기
// StageGimmick.h
protected:
UPROPERTY()
TMap<EStageState, FStageChangedDelegateWrapper> StateChangeActions;
TMap 타입으로 된 StageChangeActions라는 변수 선언.
// StageGimmick.cpp
// Delegate가 들어가있는 구조체를 배열에 차례대호 삽입.
CurrentState = EStageState::READY;
StateChangeActions.Add(EStageState::READY, FStageChangedDelegateWrapper(FOnStageChangedDelegate::CreateUObject(this, &ARyanStageGimmick::SetReady)));
StateChangeActions.Add(EStageState::FIGHT, FStageChangedDelegateWrapper(FOnStageChangedDelegate::CreateUObject(this, &ARyanStageGimmick::SetFight)));
StateChangeActions.Add(EStageState::REWARD, FStageChangedDelegateWrapper(FOnStageChangedDelegate::CreateUObject(this, &ARyanStageGimmick::SetChooseReward)));
StateChangeActions.Add(EStageState::NEXT, FStageChangedDelegateWrapper(FOnStageChangedDelegate::CreateUObject(this, &ARyanStageGimmick::SetChooseNext)));
8강에서도 보았듯이 순수 Delegate는 배열에 바로 넣을 수 없으므로 구조체로 Delegate를 한번 감싼 다음에 배열에 추가하는 모습.
Step 3
: Delegate에 bound된 함수 실행
// StageGimmick.cpp
void ARyanStageGimmick::SetState(EStageState InNewState)
{
CurrentState = InNewState;
// Contains는 TMap에서 parameter로 들어온 key값을 찾는다
if (StateChangeActions.Contains(InNewState))
{
// Bracket안에 key 값을 넣으면 key 값에 해당되는 value를 return한다.
StateChangeActions[CurrentState].StageDelegate.ExecuteIfBound();
}
}
SetState
함수를 위와 같이 구현해 주고, 코드 내에서 State가 바뀔때마다 이 SetState
함수에 현재 State 값을 Enum형태로 넣어서 call 한다.
Step 1
~ Step 3
을 통해 우리는 State를 관리하는 비밀이 Delegate에 있는 것을 알았다! Delegate은 정말 여러 곳에서 쓰이니 사용방법을 잘 알아두자:D
void ARyanStageGimmick::SetReady()
{
StageTrigger->SetCollisionProfileName(CPROFILE_RyanTRIGGER);
for (auto GateTrigger : GateTriggers)
{
GateTrigger->SetCollisionProfileName(TEXT("NoCollision"));
}
OpenAllGates();
}
void ARyanStageGimmick::SetFight()
{
StageTrigger->SetCollisionProfileName(TEXT("NoCollision"));
for (auto GateTrigger : GateTriggers)
{
GateTrigger->SetCollisionProfileName(TEXT("NoCollision"));
}
CloseAllGates();
GetWorld()->GetTimerManager().SetTimer(OpponentTimerHandle, this, &ARyanStageGimmick::OnOpponentSpawn, OpponentSpawnTime, false);
}
void ARyanStageGimmick::SetChooseReward()
{
StageTrigger->SetCollisionProfileName(TEXT("NoCollision"));
for (auto GateTrigger : GateTriggers)
{
GateTrigger->SetCollisionProfileName(TEXT("NoCollision"));
}
CloseAllGates();
SpawnRewardBoxes();
}
void ARyanStageGimmick::SetChooseNext()
{
StageTrigger->SetCollisionProfileName(TEXT("NoCollision"));
for (auto GateTrigger : GateTriggers)
{
GateTrigger->SetCollisionProfileName(CPROFILE_RyanTRIGGER);
}
OpenAllGates();
}
OnConstruction
함수는 언리얼 엔진에서 AActor 클래스의 가상 함수로, 액터가 생성되거나 에디터에서 속성이 변경될 때 호출된다. 이 함수는 액터의 초기 설정이나 속성 변경 시 필요한 로직을 실행하는 데 유용하며, 에디터와 런타임 모두에서 일관된 초기화 작업을 수행할 수 있게 한다. 이를 통해 액터의 상태가 변경될 때마다 자동으로 필요한 업데이트 작업을 처리할 수 있다.
Level Design을 할때 OnConstruction
을 사용하면 Level에 배치된 Object의 property를 editor에서 바꿀때 이를 바로 화면에서 확인할 수 있다.
위와 같이 Actor의 property가 editor에서 변경되면 OnConstruction
이 불리게 되고 이를 viewport 상에서 바로바로 확인할 수 있다.
// StageGimmick.cpp
void ARyanStageGimmick::OnConstruction(const FTransform& Transform)
{
Super::OnConstruction(Transform);
SetState(CurrentState);
}
OnConstruction
을 사용하기 위해 위와 같이 override 한다. OnConstruction
은 함수 인자로 Transform을 받게 되는데 이는 Transform의 변경 뿐만 아니라 모든 속성이 변경될 때 자동으로 호출되게 된다. 따라서, 여기 안에 SetState를 넣으면 Editor에서 변경이 일어날때마다 이 함수가 불리게 된다.
정말 이를 적극적으로 사용하면 레벨 디자인 할때 굉장히 편하게 사용할 수 있다!
TSubclassOf
는 언리얼 엔진에서 특정 클래스의 서브클래스를 가리키는 템플릿 클래스이다. 이는 주로 UClass 포인터를 안전하게 wrapping하여, 특정 클래스 유형과 그 서브클래스를 참조할 수 있도록 한다. 이를 통해 클래스 타입의 변수나 프로퍼티를 선언할 때, 해당 변수나 프로퍼티가 특정 클래스의 서브클래스만 가질 수 있도록 제한할 수 있으며, 런타임 또는 에디터에서 클래스 타입을 안전하게 선택하고 사용하게 한다. 예를 들어, TSubclassOf<AActor>
는 AActor의 서브클래스만 참조할 수 있다.
// StageGimmick.h
TSubclassOf<class ARyanNPCCharacter> OpponentClass;
이번에 사용할 NPC!
// StageGimmick.cpp
// NPC Spawn
void ARyanStageGimmick::OnOpponentSpawn()
{
//const FVector SpawnLocation = GetActorLocation() + FVector::UpVector * 88.0f;
const FVector SpawnLocation = GetActorLocation();
// 지정한 Class, 위치, 회전 값을 넘겨주어서 생성시킬 몬스터를 spawn한다.
AActor* OpponentActor = GetWorld()->SpawnActor(OpponentClass, &SpawnLocation, &FRotator::ZeroRotator);
ARyanNPCCharacter* RyanOpponentCharacter = Cast<ARyanNPCCharacter>(OpponentActor);
if (RyanOpponentCharacter)
{
RyanOpponentCharacter->OnDestroyed.AddDynamic(this, &ARyanStageGimmick::OnOpponentDestroyed);
}
}
GetWorld() -> SpawnActor()를 사용해서 NPC를 Spawn. 캐릭터를 생성하면서 OnOpponentDestroyed
함수를 OnDestroyed
delegate에 등록해준다.
// StageGimmick.cpp
// 함수 실행
void ARyanStageGimmick::SetFight()
{
...
CloseAllGates();
// 언리얼의 TimeManager를 사용해서 일정 시간이 지나면 NPC 캐릭터 Spawn
GetWorld()->GetTimerManager().SetTimer(OpponentTimerHandle, this, &ARyanStageGimmick::OnOpponentSpawn, OpponentSpawnTime, false);
}
언리얼의 TimeManager를 통해 Fight state로 transition 되었을때 일정 시간 후 NPC를 spawn하게 한다.
상자 안에 들어있는 아이템을 수동으로 설정하는 것이 아닌 자동으로 랜덤 할당하게 해보자. 이를 위해서는 언리얼에서 제공하는 Asset Manager
를 사용해보자.
Asset Manager의 특징은 다음과 같다.
지금까지는 레퍼런스 복사를 통해서 asset을 로딩했는데 PrimaryAssetId를 통한 Asset Manager로 asset을 로딩할 수도 있다.
일단 Weapon, Potion, Scroll 아이템은 다 같은 클래스를 상속한다. 따라서 Asset Manager
에서 해당 클래스를 등록하고 이후, Item.h 파일에서 해당 클래스가 Asset Manager
에서 어떤 Asset Id값을 가지는지 명시시켜준다.
// ItemData.h
public:
FPrimaryAssetId GetPrimaryAssetId() const override
{
return FPrimaryAssetId("RyanItemData", GetFName());
}
유일한 식별자 아이디 값은 "Tag 이름"과 "GetFName을 사용한 에셋의 이름" 2가지 정보를 사용해서 결정된다.
이 기능을 확장한다면 서로 다른 클래스에도 FPrimaryAssetId
를 통해 같은 tag를 부여한다면 AssetManager
입장에서는 같은 종류의 Asset으로 인지할 것이다.
// ItemBox.cpp
void ARyanItemBox::PostInitializeComponents()
{
Super::PostInitializeComponents();
UAssetManager& Manager = UAssetManager::Get();
TArray<FPrimaryAssetId> Assets;
Manager.GetPrimaryAssetIdList(TEXT("RyanItemData"), 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<URyanItemData>(AssetPtr.Get());
ensure(Item);
}
Actor의 세팅이 마무리되는 시점에 호출되는 함수인 PostInitializeComponents()
를 override한다. 이후, 함수 안에서 Asset Manager를 가져온다. Asset Manager는 엔진이 초기화될때 언제나 로딩이 보장되므로 순서상으로 문제가 없다.
아까 PrimaryAssetId
로 설정한 Asset들을 GetPrimaryAssetIdList
를 통해 리스트 형태로 쫙 불러오고, Random 함수를 사용해서 Soft Referencing으로 이들을 상자안에 하나씩 넣는다.
시작 stage에는 차별성을 두기 위해서 불상을 가운데에 배치하였다. State의 전환서부터 Gimmick의 작동까지 원활하게 작동하는 것을 확인할 수 있다.
언리얼의 좌표 시스템은 일반적인 오른손 좌표계가 아닌 왼손 좌표계이다. 따라서 외적의 방향을 구할때에도 오른손 법칙을 쓰는 것이 아닌 왼손 법칙을 사용해서 올바른 외적 벡터의 방향을 구할 수 있다.
X-axis => Forward 방향. FVector::ForwardVector
으로 표현
Y-axis => Right 방향. FVector::RightVector
으로 표현
Z-axis => Up 방향. FVector::UpVector
으로 표현
FName
은 변경할 수 없는 고정 문자열로, 주로 성능이 중요한 상황에서 빠른 문자열 비교와 해시 테이블 조회를 위해 사용되며, 메모리를 효율적으로 관리한다.
반면, FString
은 동적으로 크기를 변경할 수 있는 가변 문자열로, 다양한 문자열 조작이 가능하고, 주로 텍스트 데이터나 파일 경로 등 자주 변경되거나 조작이 필요한 문자열에 사용된다.