지난 편에서 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);
}
CarriedItem은 Replicated라, 누가 무엇을 들고 있는지가 모든 클라이언트에 동기화된다.
소켓(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 — 매 프레임 켜고 끄는 게 아니라, 대상이 달라진 순간에만 토글한다.LastActor는 TWeakObjectPtr — 하이라이트해 둔 대상이 파괴돼도 dangling 포인터가 되지 않도록 약참조로 들고 있는다.처음엔 Cast<ASPCollectibleItem>으로 수집품만 하이라이트했는데, 이러면 또 다른 Interactable한 액터에(문·케이지·납품소 등) 하이라이트를 추가할 때마다 분기를 늘려야 한다. 그래서 SetHighlight를 ISPInteractable 인터페이스로 올렸다.
// 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가 가능해졌음을 확인할 수 있었다.
