[UE5] Cpp Multiplay Game <Cop And Robber> #6

_ dfdeer·2025년 9월 25일

Cop And Robber

목록 보기
6/7

아이템 스폰 위치 꼬임 방지

실제 사용할 맵에 제대로 깔아놓고 쓰질 않아서 몰랐는데, 벽이나 바닥에 끼어서 나오거나, 아예 바닥 아래에 파묻히는 경우가 있었다.

그래서 이걸 해결할 예정이다.

// 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 가 없거나, 바운딩 박스가 유효하지 않을땐 기본값을 리턴한다.

Vision Item

이번엔 투시 아이템이다.

아이템 획득 시 다른 플레이어들의 외곽선이 빛나게되어 누가 플레이어인지 알 수 있다.

//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 를 블루프린트로 상속해주면 잘 작동했다.

버그 때문에 거의 이틀을 허비해서 조금 곤란하긴한데 그래도 어찌저찌 잘 해결돼서 다행이다.

0개의 댓글