Ch6 팀 프로젝트 (1) - Interact 시스템 구현

yys·2026년 7월 29일

TIL

목록 보기
77/86

멀티플레이 상호작용 시스템 만들기


플레이어가 벽 조각이나 아이템을 바라보면 하이라이트되고, 상호작용 키를 누르면 대상에 맞는 행동이 실행되는 시스템을 구현했다.

대상에 따라 결과도 달라야 했다.

  • 수집 아이템은 핫바에 저장
  • 벽 조각 같은 물체는 물리 상태로 직접 운반
  • 일반 상호작용 액터는 각 액터가 정의한 고유 상호작용 실행

즉, 상호작용 입력은 하나지만 대상이 가진 태그에 따라 처리 방식이 달라지는 구조다.

상호작용 인터페이스


먼저 상호작용 가능한 액터가 공통으로 사용할 인터페이스를 만들었다.

인터페이스에서 실제 동작까지 정의하면 모든 액터가 같은 행동을 강제받게 된다. 따라서 인터페이스에는 하이라이트와 상호작용 함수의 계약만 정의하고, 실제 구현은 각 액터나 Blueprint가 담당하도록 했다.

UINTERFACE(MinimalAPI, BlueprintType)
class UMD_Interactable : public UInterface
{
	GENERATED_BODY()
};

class MARINEDEMOLITION_API IMD_Interactable
{
	GENERATED_BODY()

public:
	static constexpr int32 HighlightStencilValue = 250;

	UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "MD|Interaction")
	void SetInteractionHighlighted(bool bHighlighted);

	UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "MD|Interaction")
	void Interact(AActor* Interactor);
};

BlueprintNativeEvent를 사용했기 때문에 C++ 액터에서도 구현할 수 있고, 필요한 경우 Blueprint에서 동작을 덮어쓸 수도 있다.

공통 인터페이스 구현부는 따로 만들지 않았다. 벽 조각은 벽 조각이, 샘플 아이템은 샘플 아이템이 필요한 기능만 직접 구현한다.

전용 상호작용 트레이스


처음에는 Visibility 채널을 사용하는 방법도 생각했지만, 상호작용 판정이 시야 차단이나 카메라 충돌 설정에 영향을 받는 문제가 생길 수 있었다.

그래서 Project Settings에서 InteractTrace라는 별도의 Trace Channel을 추가했다.

상호작용할 물체는 기존의 PhysicsBody 분류를 유지하면서 InteractTrace만 차단하도록 했다.

Mesh->SetCollisionProfileName(TEXT("PhysicsActor"));
Mesh->SetCollisionObjectType(ECC_PhysicsBody);
Mesh->SetCollisionResponseToChannel(MDCollisionChannels::InteractTrace, ECR_Block);

이렇게 하면 물체는 계속 물리 오브젝트로 동작하면서, 상호작용 트레이스에도 검출된다.

바라보는 대상 찾기


하이라이트는 로컬 플레이어가 매 프레임 현재 바라보는 대상을 찾도록 했다.

void AMD_Character::UpdateInteractionFocus()
{
	if (!IsLocallyControlled() || !Controller || !GetWorld())
	{
		SetCurrentTarget(nullptr);
		return;
	}

	SetCurrentTarget(TraceInteractionTarget());
}

카메라 위치에서 정면으로 InteractTrace를 발사하고, 맞은 액터가 상호작용 인터페이스를 구현했는지 확인한다.

AActor* AMD_Character::TraceInteractionTarget() const
{
	if (!Controller || !GetWorld()) return nullptr;

	FVector ViewLocation;
	FRotator ViewRotation;
	Controller->GetPlayerViewPoint(ViewLocation, ViewRotation);

	const FVector TraceEnd = ViewLocation + ViewRotation.Vector() * InteractTraceDistance;

	FHitResult Hit;
	FCollisionQueryParams QueryParams;
	QueryParams.AddIgnoredActor(this);

	if (!GetWorld()->LineTraceSingleByChannel(
		Hit,
		ViewLocation,
		TraceEnd,
		MDCollisionChannels::InteractTrace,
		QueryParams)) return nullptr;

	AActor* HitActor = Hit.GetActor();

	return IsValid(HitActor) && HitActor->Implements<UMD_Interactable>() ? HitActor	: nullptr;
}

이전에 바라보던 액터와 새로 바라보는 액터가 달라졌을 때만 하이라이트를 변경했다.

void AMD_Character::SetCurrentTarget(AActor* NewTarget)
{
	if (CurrentTarget.Get() == NewTarget) return;

	if (AActor* PreviousTarget = CurrentTarget.Get())
	{
		if (PreviousTarget->Implements<UMD_Interactable>())
        {
        	IMD_Interactable::Execute_SetInteractionHighlighted(PreviousTarget, false);
        }			
	}

	CurrentTarget = NewTarget;

	if (IsValid(NewTarget) && NewTarget->Implements<UMD_Interactable>())
	{
		IMD_Interactable::Execute_SetInteractionHighlighted(NewTarget, true);
	}
}

포스트 프로세싱 하이라이트


하이라이트는 Custom Depth와 Custom Stencil을 사용하는 포스트 프로세싱 머티리얼로 표현했다.

상호작용 대상으로 지정된 액터는 Stencil 값을 250으로 설정하고, 현재 바라보고 있을 때만 Custom Depth 렌더링을 활성화한다.

void AMD_WallPiece::SetInteractionHighlighted_Implementation(bool bHighlighted)
{
	if (IsValid(Mesh))
	{
		Mesh->SetCustomDepthStencilValue(IMD_Interactable::HighlightStencilValue);

		Mesh->SetRenderCustomDepth(bHighlighted);
	}
}

대상에서 시선을 떼면 SetRenderCustomDepth(false)가 호출되기 때문에 하이라이트도 바로 사라진다.

서버에서 다시 검증하기


현재 바라보는 대상을 찾는 작업은 반응성을 위해 로컬에서 수행하지만, 실제 상호작용까지 클라이언트의 판정을 신뢰하면 안 된다.

클라이언트는 자신이 바라보고 있다고 판단한 액터를 서버 RPC로 전달한다.

void AMD_Character::HandleInteractInput(const FInputActionValue& Value)
{
	ServerInteract(CurrentTarget.Get());
}

서버는 전달받은 액터가 유효한지 확인한 뒤, 서버 기준으로 다시 트레이스를 실행한다.

void AMD_Character::ServerInteract_Implementation(
	AActor* RequestedActor)
{
	if (!IsValid(RequestedActor) || !RequestedActor->Implements<UMD_Interactable>()
    	|| TraceInteractionTarget() != RequestedActor) return;

	// 대상에 맞는 상호작용 처리
}

클라이언트가 보낸 RequestedActor와 서버가 실제로 확인한 액터가 같을 때만 상호작용이 진행된다.

덕분에 클라이언트가 거리 밖의 액터나 시야에 없는 액터를 임의로 요청하더라도 서버에서 거부할 수 있다.

Gameplay Tag로 획득 방식 구분하기


상호작용 가능한 액터라고 해서 모두 같은 방식으로 획득되는 것은 아니다.

수집 아이템은 핫바에 들어가야 하지만, 벽 조각은 월드에 남아 있는 물리 오브젝트로 운반해야 한다.

이를 클래스 타입으로 직접 구분하면 새로운 아이템이 추가될 때마다 Character 코드에 타입 검사가 늘어난다. 그래서 획득 방식을 Gameplay Tag로 분리했다.

UE_DEFINE_GAMEPLAY_TAG_COMMENT(Interaction_Pickup_Inventory, "Interaction.Pickup.Inventory",
	"Move the interacted actor into the player's inventory");

UE_DEFINE_GAMEPLAY_TAG_COMMENT(Interaction_Pickup_Physics, "Interaction.Pickup.Physics",
	"Carry the interacted actor as a world physics object");

태그 이름은 C++에서 정의하지만, 어떤 액터가 어떤 태그를 가질지는 에디터에서 지정하도록 했다.

UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
FGameplayTag PickupMethodTag;

액터는 IGameplayTagAssetInterface를 통해 자신에게 지정된 태그를 반환한다.

void AMD_WallPiece::GetOwnedGameplayTags(FGameplayTagContainer& TagContainer) const
{
	TagContainer.Reset();

	if (PickupMethodTag.IsValid()) 
    	TagContainer.AddTag(PickupMethodTag);
}

서버는 대상이 가진 태그를 확인해 처리 방향을 결정한다.

const IGameplayTagAssetInterface* TagAsset = Cast<IGameplayTagAssetInterface>(RequestedActor);

if (TagAsset && TagAsset->HasMatchingGameplayTag(MDGameplayTags::Interaction_Pickup_Inventory))
{
	TryStoreTarget(RequestedActor);
	return;
}

if (TagAsset && TagAsset->HasMatchingGameplayTag(MDGameplayTags::Interaction_Pickup_Physics))
{
	TryHoldTarget(RequestedActor);
	return;
}

IMD_Interactable::Execute_Interact(RequestedActor, this);

정리하면 다음과 같이 처리된다.

  • Interaction.Pickup.Inventory → 핫바에 저장
  • Interaction.Pickup.Physics → Physics Handle로 운반
  • 획득 태그 없음 → 액터 고유의 Interact 실행

핫바 인벤토리


처음에는 일반적인 인벤토리 구조도 생각했지만, 현재 필요한 것은 정해진 칸을 가진 핫바뿐이었다.

그래서 슬롯에는 아이템 수량이나 월드 액터 인스턴스를 저장하지 않고, 나중에 다시 생성할 수 있는 액터 클래스만 저장했다.

USTRUCT(BlueprintType)
struct MARINEDEMOLITION_API FMD_HotbarSlot
{
	GENERATED_BODY()

	UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "MD|Hotbar")
	TSubclassOf<AActor> ItemClass;

	bool IsEmpty() const { return !ItemClass; }
};

기본 슬롯 개수는 5칸이며, 서버가 빈 슬롯을 찾아 아이템 클래스를 넣는다.

bool UMD_HotbarComponent::TryAddItem(TSubclassOf<AActor> ItemClass, int32& OutSlotIndex)
{
	OutSlotIndex = INDEX_NONE;

	AActor* Owner = GetOwner();
	if (!IsValid(Owner) || !Owner->HasAuthority() || !ItemClass) return false;

	for (int32 SlotIndex = 0; SlotIndex < Slots.Num(); ++SlotIndex)
	{
		FMD_HotbarSlot& Slot = Slots[SlotIndex];
		if (!Slot.IsEmpty()) continue;

		Slot.ItemClass = ItemClass;
		OutSlotIndex = SlotIndex;

		NotifyHotbarChanged();
		Owner->ForceNetUpdate();
		return true;
	}

	return false;
}

핫바가 가득 차면 TryAddItem이 실패하기 때문에 월드의 아이템도 제거되지 않는다.

아이템이 핫바에 들어간 뒤 월드 액터 제거에 실패하면, 방금 추가한 슬롯을 다시 비워 데이터가 어긋나지 않도록 했다.

const TSubclassOf<AActor> ItemClass = Target->GetClass();

int32 SlotIndex = INDEX_NONE;
if (!Hotbar->TryAddItem(ItemClass, SlotIndex)) return false;

if (!Target->Destroy())
{
	Hotbar->TryRemoveItemAt(SlotIndex);
	return false;
}

return true;

핫바 배치


핫바는 Character가 아니라 PlayerState에 컴포넌트로 추가했다.

Character는 사망이나 리스폰 과정에서 교체될 수 있지만, PlayerState는 플레이어의 네트워크 상태를 보관하기에 더 적합하다.

AMD_PlayerState::AMD_PlayerState()
{
	HotbarComponent = CreateDefaultSubobject<UMD_HotbarComponent>(TEXT("HotbarComponent"));
}

핫바 변경은 서버에서만 수행하고, 슬롯 정보는 해당 핫바의 소유자에게만 복제한다.

void UMD_HotbarComponent::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
	Super::GetLifetimeReplicatedProps(OutLifetimeProps);

	DOREPLIFETIME_CONDITION(UMD_HotbarComponent, Slots, COND_OwnerOnly);
}

다른 플레이어에게는 필요하지 않은 개인 핫바 데이터를 보내지 않기 때문에 불필요한 네트워크 전송도 줄일 수 있다.

아직 UI는 연결하지 않았기 때문에 AddOnScreenDebugMessage를 사용해 현재 슬롯 상태를 계속 확인하도록 했다.

constexpr uint64 HotbarKeyBase = 0x4D440000;

for (int32 SlotIndex = 0; SlotIndex < Hotbar->GetMaxSlots(); ++SlotIndex)
{
	const TSubclassOf<AActor> ItemClass = Hotbar->GetItemClassAt(SlotIndex);

	const FString ItemName = ItemClass ? ItemClass->GetName() : TEXT("비어 있음");

	GEngine->AddOnScreenDebugMessage(
		HotbarKeyBase + SlotIndex,
		0.f,
		FColor::Cyan,
		FString::Printf(TEXT("핫바 %d : %s"), SlotIndex + 1, *ItemName));
}

고정된 메시지 키를 사용했기 때문에 매 프레임 새로운 로그가 쌓이지 않고 같은 위치의 내용만 갱신된다.

Physics Handle로 물체 운반하기


벽 조각처럼 월드에 남아 있어야 하는 물체는 인벤토리로 제거하지 않고 직접 들고 이동해야 했다.

소켓이나 Scene Component에 바로 부착하면 물체가 플레이어를 정확하게 따라다니지만, 운반 중 충돌이나 무게감을 표현하기 어렵다.

그래서 카메라 앞에 HoldPoint를 배치하고 UPhysicsHandleComponent가 물체를 그 지점까지 끌어오도록 했다.

HoldPoint = CreateDefaultSubobject<USceneComponent>(TEXT("HoldPoint"));

HoldPoint->SetupAttachment(Camera);
HoldPoint->SetRelativeLocation(FVector(150.f, 0.f, 0.f));

PhysicsHandle = CreateDefaultSubobject<UPhysicsHandleComponent>(TEXT("PhysicsHandle"));

집으려는 액터의 루트 컴포넌트가 실제로 물리 시뮬레이션 중인지 확인한 뒤 Physics Handle에 전달한다.

bool AMD_Character::TryHoldTarget(AActor* Target)
{
	if (!HasAuthority()	|| !IsValid(Target)	|| !IsValid(PhysicsHandle)) return false;

	UPrimitiveComponent* Component = Cast<UPrimitiveComponent>(Target->GetRootComponent());

	if (!IsValid(Component)	|| !Component->IsSimulatingPhysics()) return false;

	PhysicsHandle->GrabComponentAtLocationWithRotation(
		Component,
		NAME_None,
		Component->GetCenterOfMass(),
		Component->GetComponentRotation());

	UpdateHeldTarget();

	return PhysicsHandle->GetGrabbedComponent() == Component;
}

서버는 매 프레임 Physics Handle의 목표 위치를 HoldPoint로 갱신한다.

void AMD_Character::UpdateHeldTarget()
{
	if (!HasAuthority()	|| !IsHolding()	|| !IsValid(HoldPoint)) return;

	PhysicsHandle->SetTargetLocationAndRotation(HoldPoint->GetComponentLocation(),
		HoldPoint->GetComponentRotation());
}

물체를 캐릭터에 직접 부착한 것이 아니기 때문에 이동 중에도 충돌과 물리 반응이 유지된다.

현재는 물체를 들고 있는 상태에서 상호작용 키를 다시 누르면 Physics Handle에서 해제하도록 구성했다.

if (IsHolding())
{
	ReleaseTarget();
	return;
}

이를 통해서 집으려는 아이템이 핫바에 넣는 것이냐 넣지 않는 것이냐에 따라서 표현이 다르게 됨을 볼 수 있다.

profile
게임 개발 지망생

0개의 댓글