
이번 프로젝트는 '경찰과 도둑' 이다.
우리는 단순히 경찰이 도둑이 아닌, 각자 군중 속에 숨어들어 플레이어끼리 서로 찾아내어 잡는 '배틀 로얄' 게임을 구현할 예정이다.
내가 맡은 파트는 '기믹' 파트이다.
게임을 진행하면서 플레이어 캐릭터에 직접적인 영향을 끼치는 필드 장애물이나, 아이템을 구현한다.
//CopAndRobber.Build.cs
using UnrealBuildTool;
public class CopAndRobber : ModuleRules
{
public CopAndRobber(ReadOnlyTargetRules Target) : base(Target)
{
PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
PublicDependencyModuleNames.AddRange(new string[]
{
//Initialize
"Core", "CoreUObject", "Engine", "InputCore", "EnhancedInput",
//GAS
"GameplayAbilities","GameplayTags","GameplayTasks"
});
PrivateDependencyModuleNames.AddRange(new string[] { });
PublicIncludePaths.AddRange(new string[] { "CopAndRobber" });
}
}
우선 CopAndRobber.Build.cs 파일이다.
소스 파일들을 모두 CopAndRobber 폴더 하위에 생성하여 관리할 예정이기에,
PublicIncludePaths.AddRange(new string[] { "CopAndRobber" });
해당 코드를 추가해주었다.

우선 설계도에 맞춰 C++ 클래스 기믹 베이스를 생성해준다.
//CRGimmickBase.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "CRGimmickBase.generated.h"
UCLASS()
class COPANDROBBER_API ACRGimmickBase : public AActor
{
GENERATED_BODY()
public:
ACRGimmickBase();
protected:
virtual void BeginPlay() override;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Gimmick")
FVector Position;
public:
virtual void Activate(AActor* Player);
virtual void Deactivate();
};
GimmickBase 헤더 파일이다.
기믹 베이스는 추상 클래스로, 모든 기믹의 부모 클래스이다.
Vector3 변수로 Position 을 지정해주었다.
그리고 Activate() 함수를 통해 오버랩된 플레이어로부터 함수를 호출 받는다.
또한 Deactivate() 함수에서 자기장에서 벗어났을 때처럼 효과가 종료될 때 호출되도록 한다.
//CRGimmickBase.cpp
#include "CRGimmickBase.h"
ACRGimmickBase::ACRGimmickBase()
{
bReplicates = true;
SetReplicateMovement(false);
Position = FVector::ZeroVector;
}
void ACRGimmickBase::BeginPlay()
{
Super::BeginPlay();
}
void ACRGimmickBase::Activate(AActor* Player){}
void ACRGimmickBase::Deactivate(){}
기믹 베이스 소스 파일이다.
기본적으로 서버에서 클라이언트로 복제하려면 bReplicates 값을 true 로 해주어야하고
SetReplicateMovement 는 false 로 둔다.
이후에 기믹의 종류에 따라 부울 값을 변경해줄 것이다.
다음은 아이템 베이스이다.


다음과 같이 부모 클래스를 CRGimmickBase 로 설정해주고 생성해준다.
//CRItemBase.h
#pragma once
#include "CoreMinimal.h"
#include "Gimmick/CRGimmickBase.h"
#include "CRItemBase.generated.h"
class UBoxComponent;
UCLASS()
class COPANDROBBER_API ACRItemBase : public ACRGimmickBase
{
GENERATED_BODY()
public:
ACRItemBase();
protected:
virtual void BeginPlay() override;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Item")
float Duration = 0.f;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="Item")
UBoxComponent* Collision = nullptr;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="Item")
UStaticMeshComponent* Mesh = nullptr;
UPROPERTY(ReplicatedUsing = OnRep_Picked)
bool bPicked = false;
UFUNCTION()
void OnRep_Picked();
UFUNCTION()
virtual void OnOverlapBegin(UPrimitiveComponent* OverlappedComp,
AActor* OtherActor,
UPrimitiveComponent* OtherComp,
int32 OtherBodyIndex,
bool bFromSweep,
const FHitResult& SweepResult);
void ApplyPickedVisuals(bool bHide);
public:
virtual void Activate(AActor* Player) override;
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
};
ItemBase 헤더파일이다.
아이템 베이스는 플레이어와 오버랩 시 즉시 발동되는 아이템의 추상 클래스이다.
은신과 같은 아이템은 지속 시간이 있기 때문에 Duration 이라는 변수를 만들었다.
콜리젼과 메시는 베이스이기 때문에 nullptr 로 설정해주었으며, 중복 획득을 방지하기 위해 bool 변수 bPicked 를 만들었다.
또한 이 부울 변수의 값이 변경되었을 때, 클라에서 OnRep_Picked() 함수가 호출되게 했다.
아이템은 획득하면 바로 Destroy() 함수를 호출하게 할 것이지만, 여러가지 처리를 위해 잠시 지연시키고 파괴될 것이기 때문에 이러한 변수를 만들어줬다.
ApplyPickedVisual() 함수는 플레이어가 아이템을 획득하면, 잠시 효과를 처리하는 동안 여러가지 시각적 효과를 없애 사라진 것처럼 보이게 하기 위해 만들었다.
//CRItemBase.cpp
#include "CRItemBase.h"
#include "Components/BoxComponent.h"
#include "Net/UnrealNetwork.h"
ACRItemBase::ACRItemBase()
{
bReplicates = true;
Collision = CreateDefaultSubobject<UBoxComponent>(TEXT("Collision"));
SetRootComponent(Collision);
Collision->InitBoxExtent(FVector(50.f, 50.f, 50.f));
Collision->SetCollisionEnabled(ECollisionEnabled::QueryOnly);
Collision->SetCollisionResponseToAllChannels(ECR_Ignore);
Collision->SetCollisionResponseToChannel(ECC_Pawn, ECR_Overlap);
Mesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("Mesh"));
Mesh->SetupAttachment(Collision);
PrimaryActorTick.bCanEverTick = false;
}
void ACRItemBase::BeginPlay()
{
Super::BeginPlay();
if (Collision)
{
Collision->OnComponentBeginOverlap.AddDynamic(this, &ACRItemBase::OnOverlapBegin);
}
}
void ACRItemBase::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(ACRItemBase, bPicked);
}
void ACRItemBase::OnRep_Picked()
{
ApplyPickedVisuals(bPicked);
}
void ACRItemBase::OnOverlapBegin(
UPrimitiveComponent* OverlappedComp,
AActor* OtherActor,
UPrimitiveComponent* OtherComp,
int32 OtherBodyIndex,
bool bFromSweep,
const FHitResult& SweepResult)
{
if (!HasAuthority()) return;
if (bPicked || !OtherActor) return;
if (!OtherActor->ActorHasTag("Player")) return;
bPicked = true;
OnRep_Picked();
Activate(OtherActor);
Destroy();
}
void ACRItemBase::ApplyPickedVisuals(bool bHide)
{
if (Collision)
{
Collision->SetGenerateOverlapEvents(!bHide);
Collision->SetCollisionEnabled(bHide ? ECollisionEnabled::NoCollision : ECollisionEnabled::QueryOnly);
}
if (Mesh)
{
Mesh->SetHiddenInGame(bHide);
Mesh->SetVisibility(!bHide, true);
}
}
void ACRItemBase::Activate(AActor* Player)
{
Super::Activate(Player);
}
아이템 베이스 소스 파일이다.
기본적으로 콜리전 설정은 캐릭터 폰 등만 오버랩되게 했고, 아이템 획득하면 어차피 사라지기 때문에 물리 충돌 옵션은 껐다.
만약 플레이어가 아이템에 오버랩되면 우선 서버가 아닌지, bPicked 가 false 이고 OtherActor 가 존재하며 Player 태그를 달고 있는 지를 체크한다.
모든 조건을 통과하면 bPicked 를 true 로 바꿔주고 OnRep_Picked() 함수를 통해 클라에 변경값을 복제하며 ApplyPickedVisuals() 함수로 콜리젼과 메시를 전부 꺼준다.
이후 아이템의 효과를 활성화 한 뒤, 파괴한다.

ExtraLifeItem 이다. CRItemBase 를 부모 클래스로 선택해서 생성해준다.
//CRExtraLifeItem.h
#pragma once
#include "CoreMinimal.h"
#include "Gimmick/Item/Base/CRItemBase.h"
#include "CRExtraLifeItem.generated.h"
UCLASS()
class COPANDROBBER_API ACRExtraLifeItem : public ACRItemBase
{
GENERATED_BODY()
public:
ACRExtraLifeItem() { Duration = 0.f; };
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Item|ExtraLife")
int32 LifeAmount = 1;
protected:
virtual void Activate(AActor* Player) override;
};
CRExtraLifeItem 헤더 파일이다.
이 아이템은 추가 목숨을 획득하는 아이템이다.
일단 바로 발동되고 즉시 적용되는 아이템이기에, Duration 은 0으로 설정했다.
또한 추가되는 목숨의 변수인 LifeAmount 값은 1로 설정했다.
//CRExtraLifeItem.cpp
#include "CRExtraLifeItem.h"
void ACRExtraLifeItem::Activate(AActor* Player)
{
Super::Activate(Player);
if (!HasAuthority() || !Player) return;
// GrantLife()
UE_LOG(LogTemp, Warning, TEXT("%s got +%d Life"), *Player->GetName(), LifeAmount);
}
소스 파일이다.
GrantLife() 함수를 통해 목숨을 증가시킬 예정이지만, 일단은 로그로 대체했다.
StealthItem 이다. 아이템이므로 아까와 같이 C++ 클래스로 생성해준다.
// CRStealthItem.h
#pragma once
#include "CoreMinimal.h"
#include "Gimmick/Item/Base/CRItemBase.h"
#include "CRStealthItem.generated.h"
UCLASS()
class COPANDROBBER_API ACRStealthItem : public ACRItemBase
{
GENERATED_BODY()
public:
ACRStealthItem() { Duration = 10.f; };
protected:
virtual void Activate(AActor* Player) override;
};
StealthItem 헤더 파일이다.
스텔스 아이템은 플레이어의 모습을 감추는 은신 아이템이다.
지속 시간은 10초이고, Acivate() 함수를 통해 은신을 활성화한다.
// CRStealthItem.cpp
#include "CRStealthItem.h"
void ACRStealthItem::Activate(AActor* Player)
{
Super::Activate(Player);
if (!HasAuthority() || !Player) return;
// ActivateStealth()
UE_LOG(LogTemp, Warning, TEXT("%s is invisible during %f sec"), *Player->GetName(), Duration);
}
마찬가지로 ActivateStealth() 함수를 통해 은신 상태를 활성화 할 것이지만 로그로 대체해두었다.
마지막으로 아이템들이 랜덤으로 스폰되는 포인트를 찾아주는 아이템 스폰 볼륨이다.

Actor 를 부모 클래스로 C++ 클래스를 생성해준다.
// CRItemSpawnVolume.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "CRItemSpawnVolume.generated.h"
class UBoxComponent;
class ACRItemBase;
UCLASS()
class COPANDROBBER_API ACRItemSpawnVolume : public AActor
{
GENERATED_BODY()
public:
ACRItemSpawnVolume();
protected:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Spawn")
UBoxComponent* Box;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Spawn")
TArray<TSubclassOf<ACRItemBase>> ItemClasses;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Spawn")
float SpawnInterval = 10.f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Spawn")
int32 MaxSpawnCount = 3;
UPROPERTY()
TArray<TWeakObjectPtr<ACRItemBase>> SpawnedItems;
int32 PendingRespawns = 0;
FTimerHandle SpawnLoopTimer;
FTimerHandle RespawnTimer;
virtual void BeginPlay() override;
void SpawnLoop();
void DoPendingRespawn();
bool CanSpawnItem() const;
void CleanSpawnedItem() const;
ACRItemBase* SpawnItem();
FVector* PickPointInBox() const;
TSubclassOf<ACRItemBase> PickItemClass() const;
UFUNCTION()
void OnItemDestroyed();
};
아이템 스폰 볼륨 헤더 파일이다.
우선 스폰 볼륨의 크기를 정하기 위한 BoxComponent.
그리고 여러가지 아이템 클래스들을 담을 ItemClasses.
아이템 스폰 주기인 SpawnInterval.
필드 내 최대 아이템 스폰 수 MaxSpawnCount.
필드에 스폰한 아이템을 Weak 포인터를 이용해 SpawnedItems 배열에 담아주고,
만약 플레이어가 아이템을 획득했을 때 리스폰을 해야하기 때문에 대기 중인 아이템 수를 PendingRespawns 에 담는다.
이후 아이템 최초 스폰 루프 타이머와 리스폰 타이머를 만들어주었다.
아래는 아이템 스폰 볼륨 소스 파일이다.
// CRItemSpawnVolume.cpp
#include "CRItemSpawnVolume.h"
#include "Components/BoxComponent.h"
#include "Gimmick/Item/Base/CRItemBase.h"
#include "Kismet/KismetMathLibrary.h"
ACRItemSpawnVolume::ACRItemSpawnVolume()
{
Box = CreateDefaultSubobject<UBoxComponent>(TEXT("Box"));
SetRootComponent(Box);
Box->SetBoxExtent(FVector(100.0f, 100.0f, 10.0f));
Box->SetCollisionEnabled(ECollisionEnabled::NoCollision);
PrimaryActorTick.bCanEverTick = false;
}
먼저 생성자이다.
단순히 박스 컴포넌트를 루트로 설정하고, 박스의 크기 설정과 콜리전 제거를 했다.
void ACRItemSpawnVolume::BeginPlay()
{
Super::BeginPlay();
if (!HasAuthority()) return;
GetWorldTimerManager().SetTimer(
SpawnLoopTimer,
this,
&ACRItemSpawnVolume::SpawnLoop,
SpawnInterval,
true,
SpawnInterval
);
}
BeginPlay() 함수이다.
아이템들은 모두 서버에서 관리되어야하기에 HasAuthority() 함수를 통해 서버인지 확인한다.
또한 SpawnLoopTimer 를 통해 SpawnLoop() 함수를 SpawnInterval 마다 주기적으로 체크하여 호출한다.
void ACRItemSpawnVolume::SpawnLoop()
{
CleanSpawnedItemPtr();
if (CanSpawnItem())
{
SpawnItem();
}
}
SpawnLoop() 함수이다.
CleanSpawnedItemPtr() 함수로 스폰한 아이템의 포인터를 초기화 해주고,
만약 CanSpawnItem() 함수를 통해 현재 아이템 스폰이 가능하다면 SpawnItem() 함수를 호출하여 스폰한다.
void ACRItemSpawnVolume::DoPendingRespawn()
{
CleanSpawnedItemPtr();
if (PendingRespawns > 0 && CanSpawnItem())
{
SpawnItem();
PendingRespawns--;
}
if (PendingRespawns > 0)
{
GetWorldTimerManager().SetTimer(
RespawnTimer,
this,
&ACRItemSpawnVolume::DoPendingRespawn,
SpawnInterval,
false
);
}
}
DoPendingRespawn() 함수이다.
마찬가지로 포인터를 초기화해주고,
대기중인 리스폰이 존재하고 아이템 스폰이 가능한 상태라면 아이템을 스폰하고 대기중인 리스폰을 1 감소시킨다.
만약 감소시킨 뒤에도 대기중인 리스폰이 있다면, 해당 함수를 SpawnInterval 주기에 맞게 한 번 더 호출한다.
bool ACRItemSpawnVolume::CanSpawnItem() const
{
int32 SpawnedItemCount = 0;
for (auto& It : SpawnedItems) if (It.IsValid()) ++SpawnedItemCount;
return SpawnedItemCount < MaxSpawnCount;
}
void ACRItemSpawnVolume::CleanSpawnedItemPtr()
{
for (int32 i = SpawnedItems.Num()-1; i >= 0; --i)
{
if (!SpawnedItems[i].IsValid())
{
SpawnedItems.RemoveAt(i);
}
}
}
CanSpawnItem() 함수이다.
현재 아이템 스폰이 가능한지 체크해주는 함수로,
스폰된 아이템들의 포인터를 가져오고 만약 가져온 아이템들이 모두 유효하며 이들의 수가 MaxSpawnCount 보다 작다면 true 를 반환한다.
ACRItemBase* ACRItemSpawnVolume::SpawnItem()
{
TSubclassOf<ACRItemBase> ItemClass = PickItemClass();
if (!*ItemClass) return nullptr;
const FVector Location = PickPointInBox();
FActorSpawnParameters P;
P.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AdjustIfPossibleButAlwaysSpawn;
ACRItemBase* Item = GetWorld()->SpawnActor<ACRItemBase>(ItemClass, Location, FRotator::ZeroRotator, P);
if (Item)
{
SpawnedItems.Add(Item);
Item->OnDestroyed.AddDynamic(this, &ACRItemSpawnVolume::OnItemDestroyed);
}
return Item;
}
SpawnItem() 함수이다.
이름 그대로 아이템을 스폰하는 함수로, PickItemClass() 함수를 통해 아이템 클래스를 하나 가져온다.
만약 유효한 클래스라면 PickPointInBox() 함수를 통해 스폰될 위치를 정한다.
FActorSpawnParameteres 를 통해 만약 스폰 위치에 뭔가가 겹치더라도 살짝 빗겨나서 스폰되도록 해주었다.
이후 아이템을 스폰하며 동시에 포인터로 저장해둔다.
이 아이템이 파괴될 때는 OnItemDestroyed() 함수가 호출되도록 했다.
이 함수는 해당 아이템 클래스를 반환한다.
FVector ACRItemSpawnVolume::PickPointInBox() const
{
return UKismetMathLibrary::RandomPointInBoundingBox(Box->GetComponentLocation(), Box->GetScaledBoxExtent());
}
TSubclassOf<ACRItemBase> ACRItemSpawnVolume::PickItemClass() const
{
if (ItemClasses.Num() == 0) return nullptr;
const int32 Idx = FMath::RandRange(0, ItemClasses.Num() - 1);
return ItemClasses[Idx];
}
PickPointInBox() 함수이다.
스폰 볼륨 박스의 크기 만큼에서 랜덤한 포인트를 잡아 벡터값으로 반환한다.
void ACRItemSpawnVolume::OnItemDestroyed(AActor* Dead)
{
if (!HasAuthority()) return;
for (int32 i = SpawnedItems.Num()-1; i >= 0; --i)
{
if (!SpawnedItems[i].IsValid() || SpawnedItems[i].Get() == Dead)
{
SpawnedItems.RemoveAt(i);
}
}
PendingRespawns++;
if (!GetWorldTimerManager().IsTimerActive(RespawnTimer))
{
GetWorldTimerManager().SetTimer(
RespawnTimer,
this,
&ACRItemSpawnVolume::DoPendingRespawn,
SpawnInterval,
false
);
}
}
마지막으로 OnItemDestroyed() 함수이다.
서버에서만 작동해야하며,
만약 스폰된 아이템의 포인터가 유효하지 않거나 액터가 존재하지 않다면 SpawnedItems 배열에서 제거한다.
이후 리스폰 대기열을 1 증가시키고 만약 리스폰 타이머가 돌아가지 않고 있다면 타이머를 작동시키고 DoPendingRespawn() 함수를 호출하여 리스폰을 시작한다.

지금까지 만든 아이템, 스폰 볼륨 모두 블루프린트로 상속시켜주었고,
스폰 볼륨 블루프린트의 Details 탭에서 Spawn > Item Classes 에 내 아이템 블루프린트들을 넣어주었다.


우선 랜덤한 아이템이 랜덤한 장소에 스폰되는 건 모두 잘 작동한다.
겹치는건 저 박스 메시가 콜리젼보다 크게 설정되어있어서 그렇고, 나중에 줄이면 된다.
아이템 획득과 리스폰도 테스트를 하고 싶지만 캐릭터가 없어서 이건 나중에 해봐야겠다.
아이템은 그냥 형태만 갖추고 실질적으로 효과가 적용되지는 않을 거라 조금 아쉽다.
아마 캐릭터 쪽에 직접 코드를 적어야할 것 같아서 아직 구현을 하지 않았는데,
조금 더 찾아보고 내 코드 내에서 처리가 안 되면 그냥 캐릭터 코드를 기다리는 게 맞는 것 같다.
기다리는 동안에는 자기장을 구현해보고 있어야겠다.
그리고 스폰 관련은 전에 했던 내용이라 쉬울 줄 알았는데 다시 하려니 꽤나 복잡해서 애먹었다...
지난번이랑은 로직이 달라야하는 게임이기도 하고 해서 그런 것도 같다..
복습은 꾸준히 해야겠다고 느꼈다.