실제 사용할 맵에 제대로 깔아놓고 쓰질 않아서 몰랐는데, 벽이나 바닥에 끼어서 나오거나, 아예 바닥 아래에 파묻히는 경우가 있었다.
그래서 이걸 해결할 예정이다.
// CRItemSpawnVolume.h
...
class COPANDROBBER_API ACRItemSpawnVolume : public AActor
{
...
private:
bool FindGroundedLocation(const FVector& XY, FHitResult& OutHit) const;
float GetItemHalfHeight(TSubclassOf<ACRItemBase> ItemClass) const;
};
헤더 파일에서 함수 2개를 선언해준다.
스폰 가능한 위치를 찾는 함수와, 아이템의 절반 높이를 찾는 함수이다.
아이템의 좌표는 메시의 절반을 가리키기 때문에 스폰을 하게되면 반 만큼 땅에 묻힌채 나오게된다.
하지만 아이템 높이의 절반만큼을 더해서 올리면 제대로 멀쩡하게 스폰을 하게된다.
// CRItemSpawnVolume.cpp
...
static const float TraceUp = 3000.f;
static const float TraceDown = 6000.f;
...
먼저 트레이싱할 범위를 미리 선언해준다.
위로 3000cm, 아래로 6000cm 만큼 쏜다.
ACRItemBase* ACRItemSpawnVolume::SpawnItem()
{
...
const FVector Center = Box->GetComponentLocation();
const FVector Extent = Box->GetScaledBoxExtent();
const FVector RandLocal = UKismetMathLibrary::RandomPointInBoundingBox(FVector::ZeroVector, Extent);
const FVector XY = Center + FVector(RandLocal.X, RandLocal.Y, 0.f);
FHitResult Hit;
if (!FindGroundedLocation(XY, Hit)) return nullptr;
const float HalfHeight = GetItemHalfHeight(ItemClass);
const float SmallLift = 2.f;
const FVector PlaceLoc = Hit.ImpactPoint + Hit.ImpactNormal * (HalfHeight + SmallLift);
const FRotator Rot = FRotator::ZeroRotator;
const FTransform SpawnXf(Rot, PlaceLoc);
...
ACRItemBase* Item = GetWorld()->SpawnActor<ACRItemBase>(ItemClass, SpawnXf, P);
...
}
먼저 SpawnItem() 함수이다.
스폰볼륨의 중앙값과 끝값을 가져오고, 박스 내의 랜덤 위치를 가져와준다.
그리고 XY 변수에 박스의 중앙값으로부터 랜덤한 위치를 더해 월드좌표를 계산해준다.
트레이싱을 통해 히트된 좌표로부터 아이템의 절반 높이와 오프셋 2.f 를 더해서 스폰 좌표를 설정해주고,
그 위치에 스폰을 해준다.
bool ACRItemSpawnVolume::FindGroundedLocation(const FVector& XY, FHitResult& OutHit) const
{
const FVector Start = XY + FVector(0, 0, TraceUp);
const FVector End = XY - FVector(0, 0, TraceDown);
FCollisionQueryParams Params(SCENE_QUERY_STAT(ItemGroundTrace), false);
Params.AddIgnoredActor(this);
return GetWorld()->LineTraceSingleByChannel(OutHit, Start, End, ECC_WorldStatic, Params);
}
FindGroundedLocation() 함수이다.
랜덤한 포인트의 좌표에서, Z축을 아까 미리 선언해둔 만큼 정해준다.
그리고 라인트레이싱을 하고 자기자신은 충돌에서 제외한다.
float ACRItemSpawnVolume::GetItemHalfHeight(TSubclassOf<ACRItemBase> ItemClass) const
{
if (!*ItemClass) return 50.f;
if (const ACRItemBase* CDO = Cast<ACRItemBase>(ItemClass->GetDefaultObject()))
{
const FBox Bounds =CDO->GetComponentsBoundingBox(true);
if (Bounds.IsValid) return Bounds.GetExtent().Z;
}
return 50.f;
}
GetItemHalfHeight() 함수이다.
아이템이 유효하지 않다면 기본값으로 50.f 를 넣어준다.
클래스 기본 오브젝트로 아이템을 가져와 실제로 스폰을 하지 않고도 아이템의 기본 크기를 확인할 수 있다.
혹시 다른 타입일 걸 대비해 캐스팅도 해주었다.
그렇게 액터에 달린 컴포넌트의 바운딩 박스 합을 계산해 Bounds 변수에 담아주고,
GetExtent() 함수로 절반 크기만 가져와준다.
CDO 가 없거나, 바운딩 박스가 유효하지 않을땐 기본값을 리턴한다.
이번엔 투시 아이템이다.
아이템 획득 시 다른 플레이어들의 외곽선이 빛나게되어 누가 플레이어인지 알 수 있다.
//CRRevealItem.h
#pragma once
#include "CoreMinimal.h"
#include "Gimmick/Item/Base/CRItemBase.h"
#include "CRRevealItem.generated.h"
UCLASS()
class COPANDROBBER_API ACRRevealItem : public ACRItemBase
{
GENERATED_BODY()
public:
ACRRevealItem() { Duration = 10.f; };
UPROPERTY(EditDefaultsOnly, Category="GAS|Effects")
TSubclassOf<class UGameplayEffect> GE_Reveal;
protected:
virtual void Activate(AActor* Player) override;
};
헤더 파일이다.
//CRRevealItem.cpp
#include "CRRevealItem.h"
#include "AbilitySystemGlobals.h"
#include "AbilitySystemComponent.h"
#include "GameplayEffect.h"
void ACRRevealItem::Activate(AActor* Player)
{
if (!HasAuthority() || !Player || !GE_Reveal) return;
Super::Activate(Player);
if (UAbilitySystemComponent* ASC = UAbilitySystemGlobals::GetAbilitySystemComponentFromActor(Player))
{
FGameplayEffectContextHandle Ctx = ASC->MakeEffectContext();
Ctx.AddSourceObject(this);
FGameplayEffectSpecHandle Spec = ASC->MakeOutgoingSpec(GE_Reveal, 1.f, Ctx);
if (Spec.IsValid())
{
Spec.Data->SetDuration(Duration, true);
ASC->ApplyGameplayEffectSpecToSelf(*Spec.Data.Get());
}
}
UE_LOG(LogTemp, Warning, TEXT("Reveal Enemies"));
}
소스 파일이다.
이 부분은 전에 만든 아이템들과 다를 게 없기 때문에 설명은 스킵.
//GCN_CRReveal.h
#pragma once
#include "CoreMinimal.h"
#include "GameplayCueNotify_Looping.h"
#include "GCN_CRReveal.generated.h"
UCLASS()
class COPANDROBBER_API AGCN_CRReveal : public AGameplayCueNotify_Looping
{
GENERATED_BODY()
public:
AGCN_CRReveal();
UPROPERTY(EditAnywhere, Category = "Reveal")
int RevealStencil = 250;
virtual bool OnActive_Implementation(AActor* Target, const FGameplayCueParameters& Parameters) override;
virtual bool OnRemove_Implementation(AActor* Target, const FGameplayCueParameters& Parameters) override;
private:
mutable TSet<TWeakObjectPtr<AActor>> OutlinedActors;
void ApplyRevealForLocal(AActor* Target) const;
void ClearRevealForLocal();
void SetOutline(AActor* Target, bool bEnable) const;
bool IsOtherPlayer(AActor* Actor, const APawn* LocalPawn) const;
static bool IsOwner(const AActor* Target);
};
GCN_CRREveal 헤더 파일이다.
RevealStencil 값을 250 으로 설정했다.
외곽선을 표시할 플레이어들을 TSet 을 통해 중복을 허용하지 않고, 순서도 신경쓰지 않는 컨테이너에 담는다.
그리고 이 외곽선이 표시된 플레이어들을 볼 수 있게 해주는 함수와 이를 제거하는 함수도 만들었고,
외곽선을 표시하는 함수, 오너인지 확인하는 함수, 본인이 아닌 다른 플레이어인지 확인하는 함수도 만들어주었다.
//GCN_CRReveal.cpp
#include "GCN_CRReveal.h"
#include "EngineUtils.h"
#include "Kismet/GameplayStatics.h"
AGCN_CRReveal::AGCN_CRReveal()
{
GameplayCueTag = FGameplayTag::RequestGameplayTag(TEXT("GameplayCue.Reveal"));
bAutoDestroyOnRemove = true;
bAllowMultipleOnActiveEvents = false;
bAutoAttachToOwner = true;
}
bool AGCN_CRReveal::OnActive_Implementation(AActor* Target, const FGameplayCueParameters& Parameters)
{
if (!Target || Target->GetNetMode() == NM_DedicatedServer) return true;
if (IsOwner(Target))
{
ApplyRevealForLocal(Target);
}
return true;
}
bool AGCN_CRReveal::OnRemove_Implementation(AActor* Target, const FGameplayCueParameters& Parameters)
{
if (!Target || Target->GetNetMode() == NM_DedicatedServer) return true;
if (IsOwner(Target))
{
ClearRevealForLocal();
}
return true;
}
소스 파일이다.
생성자는 이전에 설명했었고,
아래 함수들은 태그를 입히고 지우는 GCN 기본 함수에 내가 만든 적용/제거 함수를 호출하도록 했다.
void AGCN_CRReveal::ApplyRevealForLocal(AActor* Target) const
{
if (!Target) return;
UWorld* World = Target->GetWorld();
if (!World) return;
APlayerController* LocalPC = UGameplayStatics::GetPlayerController(World, 0);
if (!LocalPC) return;
APawn* LocalPawn = LocalPC->GetPawn();
if (!LocalPawn) return;
for (auto It = OutlinedActors.CreateIterator(); It; ++It)
{
if (!It->IsValid())
{
It.RemoveCurrent();
continue;
}
AActor* Actor = It->Get();
if (!IsOtherPlayer(Actor, LocalPawn))
{
SetOutline(Actor, false);
It.RemoveCurrent();
}
}
for (TActorIterator<APawn> ItPawn(World); ItPawn; ++ItPawn)
{
APawn* P = *ItPawn;
if (!P) continue;
if (!IsOtherPlayer(P, LocalPawn)) continue;
if (!OutlinedActors.Contains(P))
{
SetOutline(P, true);
OutlinedActors.Add(P);
}
}
}
ApplyRevealForLocal() 함수이다.
아이템을 획득한 플레이어가 투시를 할 수 있게 활성화 해주는 함수이다.
OutlinedActors 를 가져와 유효하지 않거나 다른 플레이어가 아닌 본인이라면 외곽선을 지우고 목록에서 제거한다.
그리고 아래 반복문에서 만약 목록에 다른 플레이어의 폰이 없다면,
외곽선을 표시하고 목록에 추가한다.
void AGCN_CRReveal::ClearRevealForLocal() const
{
for (auto& WeakA : OutlinedActors)
{
if (AActor* A = WeakA.Get())
{
SetOutline(A, false);
}
}
OutlinedActors.Empty();
}
ClearRevealForLocal() 함수이다.
TWeak 포인터로 불러온 액터들을 가져와 실제 액터로 가져오고 외곽선을 해제한다.
그리고 외곽선 표시 플레이어 목록을 비운다.
void AGCN_CRReveal::SetOutline(AActor* Target, bool bEnable) const
{
if (!Target) return;
TArray<USkeletalMeshComponent*> Meshes;
Target->GetComponents(Meshes);
for (USkeletalMeshComponent* Mesh : Meshes)
{
if (!Mesh) continue;
Mesh->SetRenderCustomDepth(bEnable);
if (bEnable)
{
Mesh->SetCustomDepthStencilWriteMask(ERendererStencilMask::ERSM_Default);
Mesh->SetCustomDepthStencilValue(RevealStencil);
}
}
}
SetOutline() 함수이다.
스켈레탈 메시를 배열로 선언해주고,
액터의 스켈레탈 메시 컴포넌트를 가져와 배열에 담는다.
반복문을 통해 해당 액터의 모든 메시에 SetRenderCustomDepth() 함수를 통해 bEnable 가 true 라면 해당 메시를 Custom Depth 버퍼에 렌더링 시킨다.
그러면 Post Process 머티리얼에서 이 메시에서 윤곽선을 딸 수 있게 된다.
외곽선을 키게 되면 스텐실 값과 마스크를 세팅해준다.
마스크는 모든 채널을 사용하도록하고,
스텐실 번호는 미리 설정해둔 번호를 넣어준다.
이따가 PP 머티리얼에서 해당 번호만 외곽선이 빛나게 할 것이다.
bool AGCN_CRReveal::IsOtherPlayer(AActor* Actor, const APawn* LocalPawn) const
{
const APawn* P = Cast<APawn>(Actor);
if (!P) return false;
return (P != LocalPawn) && P->IsPlayerControlled();
}
IsOtherPlayer() 함수이다.
단순하게 해당 액터의 폰이 본인이 아니고 다른 플레이어의 것인지 확인하는 함수이다.
bool AGCN_CRReveal::IsOwner(const AActor* Target)
{
const APawn* Pawn = Cast<APawn>(Target);
return Pawn && Pawn->IsLocallyControlled();
}
마지막으로 IsOwner() 함수이다.
OnActive(), OnRemove() 함수에서 아이템을 주운 사람인지 확인하기 위한 함수이다.

이후 BP_GE_Reveal 을 만들어 이전에 Stealth 를 만들때와 똑같이 설정해주면 된다.
정확한 내용을 아래 링크를 참고.
https://velog.io/@dfdeer/UE5-Cpp-Multiplay-Game-Cop-And-Robber-2

Project Settings 에서 Engine > Rendering > Postprocessing > Custom Depth-Stencil Pass 를 Enabled with Stencil 로 바꿔준다.
외곽선을 표시하기 위해 필요한 설정이다.


머티리얼 M_PP_RevealOutline 을 생성해주었다.
Details 패널에서
Material > Material Domain 을 Post Process 로,
Post Process Material > Blendable Location 을 Scene Color Before DOF 로 설정해준다.


이제 맵에 배치된 PostProcessVolume 의 Details 패널에서,
Post Process Materials > Array 에 + 버튼을 누르고
아까 만든 M_PP_RevealOutline 머티리얼을 넣어준다.
그리고 Post Process Volume Settings > Infinite Extent (Unbound) 를 체크해준다.

머티리얼 그래프 노드이다.
스텐실 값이 250인 메시에 OutlineColor 로 빛나게 하는 구조이다.


다른 플레이어가 빛나고, 벽 뒤에 있어도 잘 작동한다.
GameplayCue 는 경로 지정에 굉장히 민감하다는 것을 깨달았다..
GC 블루프린트 위치가 맘에 안들어서 폴더 위치를 옮겼다가 문제가 났길래 다시 되돌렸는데 그래도 문제가 있어서
그 전에 문제가 있었나..? 했는데 다른 곳에선 문제가 전혀 없었다..
그냥 GC 파일은 한 번 만들었으면 절대 옮기지 말아야겠다.
대신 해결책을 찾았는데,
그냥 GC 태그를 지우고 블루프린트로 상속한 GC 도 지운 뒤 에디터를 재시작한다.
그리고 태그를 다시 만들고 C++ 클래스로 만든 GC 를 블루프린트로 상속해주면 잘 작동했다.
버그 때문에 거의 이틀을 허비해서 조금 곤란하긴한데 그래도 어찌저찌 잘 해결돼서 다행이다.