Ch4 팀 프로젝트(3) - State 분기 및 Interact 처리

yys·2026년 6월 30일

TIL

목록 보기
63/86

상태 효과 적용


지난 편에서 ESurvivorState와 리플리케이션을 처리하고 각 State에 따른 분기를 처리하는 함수인 ApplyStateEffects로 모아주는 구조까지 잡았다. 오늘은 이 함수를 채워주려고 한다.

먼저 이동속도 같은 수치는 코드에 박지 않고 데이터 애셋(SurvivorData)에서 읽기 때문에 에셋 할당 여부를 체크해주고 그 이후 분기처리 한다.

void ASurvivorCharacter::ApplyStateEffects()
{
	UCharacterMovementComponent* MoveComp = GetCharacterMovement();
	if (!MoveComp || !SurvivorData) return;

	switch (SurvivorState)
	{
	case ESurvivorState::Healthy:
		MoveComp->MaxWalkSpeed = SurvivorData->SurvivorSprintSpeed;
		break;

	case ESurvivorState::Injured:
		MoveComp->MaxWalkSpeed =
			SurvivorData->SurvivorSprintSpeed * SurvivorData->SurvivorInjuredSpeedMultiplier;
		break;

	case ESurvivorState::Downed:
		MoveComp->MaxWalkSpeed = DownedWalkSpeed;
		break;

	default:
		// Carried / Caged / Dead / Escaped : 
		break;
	}

	// 이동 불가 상태에선 이동 입력만 차단하고, 카메라 회전은 살려둔다
	if (AController* Controller = GetController())
	{
		Controller->ResetIgnoreMoveInput();
		if (!CanMove())
		{
			Controller->SetIgnoreMoveInput(true);
		}
	}
}

여기서 제일 신경 쓴 부분은 이동 불가 상태의 처리 방식이다.

처음엔 단순하게 MaxWalkSpeed = 0으로 막으려 했다. 그런데 그건 입력이 계속 처리되면서 속도만 0이 되는 거라 깔끔하지 않기 때문에 SetIgnoreMoveInput 으로 바꿨다.

  • SetIgnoreMoveInput(true)AddMovementInput을 무시하는 함수
  • 카메라 회전은 그대로 — 시점(Look)은 별개의 행동이라 영향을 받지 않는다. 업히거나 케이지에 갇혀도 주변은 둘러볼 수 있어야 하기 때문에 따로 막지 않았다.
  • SetIgnoreMoveInput누적 카운터 형식이라서, 매번 ResetIgnoreMoveInput()으로 초기화한 뒤 다시 거는 식으로 안전하게 다뤘다.

수집품 줍기


이번에는 수집품을 처리하고 하는데, 수집품이 IInteractable만 구현하면, 이미 만들어둔 "바라보고 → 서버 트레이스 → Execute_Interact" 흐름이 유지된다.

void ASPCollectibleItem::Interact_Implementation(AActor* Interactor)
{
	if (ASurvivorCharacter* Survivor = Cast<ASurvivorCharacter>(Interactor))
	{
		Survivor->BeginPickup(this);
	}
}

서버 권위 + 줍기 시전

줍기도 상호작용과 똑같이 서버가 권위를 갖는다. 그리고 기획상 줍기는 즉발이 아니라 시전 시간을 갖는 것으로 처리했는데, 즉발을 원할 수도 있기 때문에 플래그도 같이 두었다.

void ASurvivorCharacter::BeginPickup(ASPCollectibleItem* Item)
{
	if (!HasAuthority() || bIsInteract || IsCarrying() || !Item || !SurvivorData) return;

	CurrentPickupItem = Item;

	if (bInstantPickup)        // 즉시 장착
	{
		CompletePickup();
		return;
	}

	bIsInteract = true;        // 상호작용 중이면 활성화
	GetWorldTimerManager().SetTimer(
		PickupDropTimer, this, &ASurvivorCharacter::CompletePickup,
		SurvivorData->PickupDuration, false);
}

실제 장착은 시전이 끝난 뒤 CompletePickup에서 일어난다.

void ASurvivorCharacter::CompletePickup()
{
	bIsInteract = false;
	if (!CurrentPickupItem.IsValid()) return;

	CarriedItem = CurrentPickupItem.Get();
	CurrentPickupItem = nullptr;

	CarriedItem->SetPickupCollisionEnabled(false);
	CarriedItem->AttachToComponent(
		GetMesh(),
		FAttachmentTransformRules::SnapToTargetNotIncludingScale,
		CarrySocketName);
}

CarriedItemReplicated라, 누가 무엇을 들고 있는지가 모든 클라이언트에 동기화된다.

소켓에 안 붙고 발밑에 박혔던 문제

소켓(CarrySocket)을 머리 본에 추가했는데도, 수집품이 자꾸 캐릭터 발밑(루트)에 박혔다.

원인은 부착 함수였다.

// 잘못된 버전 — 액터에 붙이면 RootComponent(캡슐) 기준이라 소켓을 못 찾는다
CarriedItem->AttachToActor(this, ..., CarrySocketName);

// 올바른 버전 — 소켓은 스켈레탈 메시에 있으니 GetMesh()에 직접 붙인다
CarriedItem->AttachToComponent(GetMesh(), ..., CarrySocketName);
  • AttachToActor(this, …) 는 액터의 RootComponent(여기선 CapsuleComponent)를 기준으로 소켓을 찾는다. 캡슐엔 CarrySocket이 없으니 못 찾고 루트 원점에 떨어졌던 것이었다.
  • 소켓은 스켈레탈 메시에 사니까 AttachToComponent(GetMesh(), …) 로 붙여야 한다.

에임 하이라이트


상호작용이 되긴 하는데, 무엇을 주울 수 있는지 눈에 보이질 않았다. 그래서 바라보는 대상에 외곽선을 입히기로 했다. UE의 Custom Depth - Stencil을 쓴다.

// SPCollectibleItem()
Mesh->SetCustomDepthStencilValue(250);   // 포스트프로세스에서 250을 외곽선으로 그림
Mesh->SetRenderCustomDepth(false);        // 기본은 꺼두고 동적으로 토글

토글은 매 프레임 로컬 플레이어가 카메라로 트레이스해서, 보고 있는 대상이 바뀔 때만 켜고 끄는 방식을 유지했다.

void ASurvivorCharacter::UpdateInteractFocus()
{
	if (!IsLocallyControlled()) return;   // 하이라이트는 자기 자신만

	AActor* ThisActor = nullptr;
	if (CanInteract() && !IsCarrying())
	{
		FHitResult Hit;
		if (TraceInteractable(Hit) && Hit.GetActor()
			&& Hit.GetActor()->Implements<USPInteractable>())
		{
			ThisActor = Hit.GetActor();
		}
	}

	// 포커스가 '바뀐 프레임'에만 이전 건 끄고 새로운 건 켤 수 있도록
	if (ThisActor != LastActor)
	{
		if (LastActor.IsValid()) ISPInteractable::Execute_SetHighlight(LastActor.Get(), false);
		if (ThisActor)           ISPInteractable::Execute_SetHighlight(ThisActor, true);
		LastActor = ThisActor;
	}
}
  • IsLocallyControlled() — 하이라이트는 서버가 아니라 각자 자기 화면에서만 처리하면 된다. 서버나 다른 클라이언트에선 돌릴 필요가 없다.
  • ThisActor != LastActor — 매 프레임 켜고 끄는 게 아니라, 대상이 달라진 순간에만 토글한다.
  • LastActorTWeakObjectPtr — 하이라이트해 둔 대상이 파괴돼도 dangling 포인터가 되지 않도록 약참조로 들고 있는다.

하이라이트도 인터페이스로

처음엔 Cast<ASPCollectibleItem>으로 수집품만 하이라이트했는데, 이러면 또 다른 Interactable한 액터에(문·케이지·납품소 등) 하이라이트를 추가할 때마다 분기를 늘려야 한다. 그래서 SetHighlightISPInteractable 인터페이스로 올렸다.

// SPInteractable.h
UFUNCTION(BlueprintNativeEvent, Category = "Interaction")
void SetHighlight(bool bEnabled);
// SPCollectibleItem.cpp
void ASPCollectibleItem::SetHighlight_Implementation(bool bEnabled)
{
	Mesh->SetRenderCustomDepth(bEnabled);
}

이제 트레이스에 잡힌 대상이 ISPInteractable을 구현하기만 하면, 종류와 무관하게 외곽선이 켜진다.

리팩토링


트레이스 함수 추출

"카메라 시점 → Start/End → 구체 스윕" 블록이 서버 상호작용(Server_Interact)로컬 하이라이트(UpdateInteractFocus) 양쪽에 똑같이 들어가 있어서 하나로 묶었다.

bool ASurvivorCharacter::TraceInteractable(FHitResult& OutHit) const
{
	const AController* ViewController = GetController();
	if (!ViewController) return false;

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

	const FVector Start = ViewLocation;
	const FVector End   = Start + ViewRotation.Vector() * InteractReach;

	FCollisionQueryParams Params;
	Params.AddIgnoredActor(this);

	return GetWorld()->SweepSingleByChannel(
		OutHit, Start, End, FQuat::Identity, ECC_GameTraceChannel1,
		FCollisionShape::MakeSphere(InteractRadius), Params);
}

덕분에 두 호출처가 if (TraceInteractable(Hit)) 한 줄로 줄었고, 빠져 있던 GetController() null 가드도 한 곳에서 챙겨졌다.

시전 취소

줍기 시전(0.80초) 도중에 피격·다운·사망이 일어나도 타이머가 안 멈춰서, 0.80초 뒤 그대로 줍기가 완료되는 허점이 있었다. 상태가 바뀌면 시전을 취소하도록 했다.

void ASurvivorCharacter::CancelInteract()
{
	if (!bIsInteract) return;

	GetWorldTimerManager().ClearTimer(PickupDropTimer);
	bIsInteract = false;
	CurrentPickupItem = nullptr;
}
void ASurvivorCharacter::SetSurvivorState(ESurvivorState NewState)
{
	if (!HasAuthority() || SurvivorState == NewState) return;

	SurvivorState = NewState;
	CancelInteract();      // 상태가 바뀌면 진행 중인 줍기/내려놓기 시전을 취소
	ApplyStateEffects();
}

상태 전이를 다루는 SetSurvivorState 한 곳에 취소를 끼워 넣으니, 어떤 경로로 상태가 바뀌든 시전이 안전하게 끊긴다. 지난 편에서 "상태를 한 함수로 모았다" 던 게, 이렇게 부수 효과를 한 곳에서 처리할 수 있게 해줬다.

전반적으로 서버/클라 모두 Interact가 가능해졌음을 확인할 수 있었다.

profile
게임 개발 지망생

0개의 댓글