이 구조를 사용하면 공통 소켓을 유지하면서도 권총과 라이플의 피벗 차이를 데이터로 보정할 수 있다.
4. 조준 중에는 걷고, 조준 해제 후 다시 달리기
요구사항
달리기 키를 계속 누르고 있는 상태에서 조준했을 때:
조준 중에는 걷기 속도로 전환
달리기 입력 상태는 유지
조준을 해제하면 다시 자동으로 달리기
단순히 조준 시작 시 StopSprint()를 호출하면 달리기 입력 상태까지 제거되어, 조준을 해제한 뒤 달리기 키를 다시 눌러야 한다.
따라서 물리적인 입력 상태와 현재 적용 중인 이동 상태를 분리해야 한다.
bSprintInputHeld
달리기 키를 현재 누르고 있는가?
bIsAiming
현재 조준 상태인가?
실제 달리기
bSprintInputHeld && !bIsAiming
조준 시작 시에는 달리기 요청을 지우지 않고 속도만 걷기로 바꾼다.
조준 종료 시 달리기 키가 여전히 눌려 있다면 다시 달리기 속도를 적용한다.
이 방식은 입력 의도와 캐릭터 상태를 분리하기 때문에 재장전, 피격, 스태미나 부족 등 새로운 상태가 추가돼도 확장하기 쉽다.
5. 애니메이션 노티파이를 이용한 발소리 처리
설계 목표
권총과 라이플뿐 아니라 전후좌우 애니메이션이 각각 존재했다. 각 애니메이션마다 별도 발소리 로직을 작성하면 중복이 급격하게 늘어난다.
대신 애니메이션에는 “발이 바닥에 닿은 시점”만 기록하고, 실제 사운드 재생은 AnimBP에서 공통 처리했다.
애니메이션 노티파이
각 이동 애니메이션에서 기존 L/R 발 접지 타이밍에 다음 Skeleton Notify를 추가했다.
Footstep_L
Footstep_R
발 접지 타이밍에 맞춘 노티파이
AnimBP에서는 각 노티파이 이벤트를 받아 발 소켓 위치를 구했다.
AnimNotify_Footstep_L
→ Get Owning Component
→ Get Socket Location("foot_l")
→ Spawn Sound at Location
AnimNotify_Footstep_R
→ Get Owning Component
→ Get Socket Location("foot_r")
→ Spawn Sound at Location
발 위치에서 재생하는 이유
캐릭터 Actor Location에서 사운드를 재생하면 양발의 위치 차이가 사라진다. 발 소켓 위치를 사용하면 다음 기능으로 확장하기 쉽다.
왼발과 오른발의 공간감 표현
지면 Physical Material 감지
바닥 재질별 사운드 변경
발자국 데칼 및 먼지 이펙트 생성
네트워크 원격 플레이어의 3D 발소리 처리
BlendSpace에서는 여러 애니메이션이 동시에 평가될 때 중복 노티파이가 발생할 수 있으므로 Notify Trigger Mode도 함께 확인해야 한다. 대표 애니메이션 하나의 노티파이만 사용하려면 Highest Weighted Animation이 적합하다.
6. 사망 몽타주와 결과 UI, 리스폰 흐름 정리
기존 흐름 분석
사망 상태는 PlayerState가 관리하고 있었다.
서버에서 피해 처리
→ 체력 0 확인
→ PlayerState의 bIsDead 변경
→ OnRep_bIsDead
→ OnPlayerbIsDeadChanged 델리게이트 Broadcast
→ Character의 OnbIsDeadChanged
→ Die()
→ 사망 몽타주 재생
→ OnDeathMontageEnded()
여기에는 두 종류의 델리게이트가 사용된다.
OnPlayerbIsDeadChanged: PlayerState 사망 상태 변경을 전달하는 Dynamic Multicast Delegate
FOnMontageEnded: 사망 몽타주 종료 시점을 전달하는 몽타주 델리게이트
결과 UI는 로컬 플레이어 화면에만 생성해야 하므로 기존 IsLocallyControlled() 조건을 유지했다.
if (IsLocallyControlled())
{
AMyPlayerController* PlayerControllerES =
Cast<AMyPlayerController>(GetController());
if (IsValid(PlayerControllerES))
{
PlayerControllerES->Client_OpenResultUI();
}
}
캐릭터를 즉시 Destroy하면 안 되는 이유
처음에는 사망 몽타주 종료 후 캐릭터를 즉시 제거하려고 했다.
하지만 기존 GameMode 리스폰 코드는 결과 UI 입력 이후 현재 Pawn을 가져와 제거하고 새 Pawn을 생성하는 구조였다.
사망 몽타주 종료 시 캐릭터를 먼저 Destroy()하면, 나중에 GameMode가 GetPawn()을 호출했을 때 유효한 Pawn을 얻지 못해 리스폰 함수가 중단될 수 있다.
따라서 캐릭터는 제거하지 않고 시각적으로만 숨겼다.
void APlayerCharacter::OnDeathMontageEnded(
UAnimMontage* Montage,
bool bInterrupted)
{
if (USkeletalMeshComponent* MeshComp = GetMesh())
{
MeshComp->SetVisibility(false, true);
}
if (HasAuthority() && IsValid(CurrentWeapon))
{
CurrentWeapon->Destroy();
CurrentWeapon = nullptr;
}
if (IsLocallyControlled())
{
AMyPlayerController* PlayerControllerES =
Cast<AMyPlayerController>(GetController());
if (IsValid(PlayerControllerES))
{
PlayerControllerES->Client_OpenResultUI();
}
}
}
무기만 서버에서 제거하는 이유
무기는 별도의 복제 Actor이므로 서버에서 제거해야 한다.
if (HasAuthority() && IsValid(CurrentWeapon))
{
CurrentWeapon->Destroy();
CurrentWeapon = nullptr;
}
서버에서 Destroy()하면 제거 상태가 클라이언트에 복제되므로, 별도로 숨긴 뒤 제거하는 이중 처리는 필수적이지 않다.
캐릭터는 GameMode의 리스폰 로직이 사용할 수 있도록 유지하고, 무기는 남아 있을 이유가 없으므로 서버에서 제거한다.
최종 사망·리스폰 흐름
사망 상태 복제
→ 사망 몽타주 재생
→ 몽타주 종료
→ 캐릭터 메시 숨김
→ 서버에서 장착 무기 제거
→ 로컬 플레이어에게 결과 UI 표시
→ 결과 UI 입력
→ GameMode가 기존 Pawn 제거
→ RestartPlayerAtTransform으로 새 Pawn 생성
→ 새 Pawn은 기본 Visibility로 정상 표시
마무리
오늘 작업에서 가장 큰 교훈은 애니메이션, 충돌, 네트워크 이동 보정이 서로 독립된 문제가 아니라는 점이었다.
특히 아래를 조준할 때 발생한 비정상 이동은 겉으로 보면 Aim Offset 또는 Root Motion 문제처럼 보였다. 하지만 캡슐까지 실제로 움직이는지 확인하고, 입력 벡터와 네트워크 이동 코드를 배제한 뒤 무기 충돌까지 추적하면서 실제 원인을 찾을 수 있었다.
또한 사망 처리에서는 단순히 “사라지게 만들기”보다 기존 리스폰 시스템이 어떤 Pawn 생명주기를 전제로 하는지 먼저 파악해야 했다. 즉시 제거 대신 가시성만 변경함으로써 기존 GameMode 구조를 깨지 않고 요구사항을 만족시켰다.