1년 뒤 시선에서 시작하는 FPS 설계/Reloading Weapons #6

공팡팡·2026년 8월 7일

ShooterRevisited

목록 보기
7/7

개요

재장전은 단순히 Ammo = MagCapacity를 대입하는 기능처럼 보이지만, FPS에서는 그 수치가 바뀌는 타이밍이 애니메이션, 서버 권위, UI 갱신과 맞아야 한다.

재장전을 완성하는 동시에, 외부 시스템이 탄약을 지급할 수 있는 입구까지 만든다.

이후 탄약 픽업, 보상, 상점, 증강 효과 같은 시스템은 CombatComponent 내부 구조를 직접 알 필요 없이 AddAmmo라는 공통 인터페이스만 호출하면 된다.

발사 스팸 방지

Reload에 들어가기 전에 먼저 발사 입력을 정리한다.

자동화기는 FireTime을 기준으로 일정한 간격마다 발사된다.

반면 반자동 무기는 클릭할 때마다 발사되기 때문에, 별도의 제한이 없다면 클릭을 빠르게 연타해서 의도한 발사 속도보다 더 빠르게 쏠 수 있다.

이를 막기 위해 발사 순간 무기 상태를 Firing으로 바꾼다.

void UCombatComponent::Local_FireWeapon()
{
    if (!IsValid(CurrentWeapon)) return;

    CurrentWeapon->WeaponStatus = EWeaponStatus::Firing;

    // Fire montage, trace, cosmetic, RPC...
}

이후 발사 입력은 Idle 상태일 때만 허용한다.

if (CurrentWeapon->WeaponStatus == EWeaponStatus::Idle &&
    CurrentWeapon->Ammo > 0)
{
    Local_FireWeapon();
}

이렇게 하면 자동화기와 반자동 무기 모두 같은 FireTime 규칙을 따른다.

자동화기는 트리거를 누르고 있을 때 타이머가 반복 발사를 이어가고, 반자동 무기는 클릭을 빠르게 연타하더라도 Firing 상태가 끝나기 전까지 다음 발사가 막힌다.

짤에서 보이다시피, 전에는 단발광클을 하면 나가던 총기가 FireTime의 제약을 받아 일정 시간(Firing Time)엔 나가지 않는다.

Fire Timer

발사 후 일정 시간이 지나면 다시 발사 가능한 상태로 돌아가야 한다. 이 타이밍은 FTimerHandleSetTimer로 만든다.

GetWorld()->GetTimerManager().SetTimer(
    FireTimer,
    this,
    &ThisClass::FireTimerFinished,
    CurrentWeapon->FireTime);

이 코드는 CurrentWeapon->FireTime초 뒤에 FireTimerFinished를 호출한다.

void UCombatComponent::FireTimerFinished()
{
    if (!IsValid(CurrentWeapon)) return;

    if (CurrentWeapon->WeaponStatus == EWeaponStatus::Firing)
    {
        CurrentWeapon->WeaponStatus = EWeaponStatus::Idle;
    }
}

여기서 중요한 점은 무조건 Idle로 되돌리지 않는다는 것이다. 발사 타이머가 끝나기 전에 재장전이나 무기 교체가 시작될 수 있기 때문이다.

이 상황에서 타이머가 무조건 Idle로 바꾸면 Reloading 상태를 깨버린다. 그래서 현재 상태가 아직 Firing일 때만 Idle로 복구한다.

Server Authoritative Ammo

탄약은 클라이언트에서 즉시 줄어드는 것처럼 보여야 조작감이 좋다. 하지만 최종 권위는 서버에 있어야 한다.

발사 요청이 서버에 도착했을 때, 서버는 실제 무기 탄약을 확인한다.

void UCombatComponent::Server_FireWeapon_Implementation(const FHitResult& Hit)
{
    if (!IsValid(CurrentWeapon)) return;
    if (CurrentWeapon->Ammo <= 0) return;

    CurrentWeapon->Auth_Fire();
    Multicast_FireWeapon(Hit, CurrentWeapon->Ammo);
}

클라이언트가 예측으로 발사했더라도 서버 기준 탄약이 0이면 발사를 거부한다.

이 검증이 없으면 클라이언트 입력이 빠르거나 지연 보정이 꼬였을 때, 서버가 허용하지 않은 발사가 시각적으로 계속 이어질 수 있다.

서버의 검증을 거치지 않을 경우 탄약이 0이 되었음에도, 끊임없이 총알이 나가게 된다.

Reload 입력

재장전 입력은 Initiate_ReloadWeapon에서 시작한다.

void UCombatComponent::Initiate_ReloadWeapon()
{
    if (!IsValid(CurrentWeapon)) return;
    if (CurrentWeapon->WeaponStatus == EWeaponStatus::Cycling ||
        CurrentWeapon->WeaponStatus == EWeaponStatus::Reloading) return;
    if (CurrentWeapon->Ammo == CurrentWeapon->MagCapacity) return;
    if (CurrentReserveAmmo == 0) return;

    Local_ReloadWeapon();
    Server_ReloadWeapon();
}

이 함수의 역할은 실제 탄약을 채우는 것이 아니다. 재장전을 시작할 수 있는 조건을 확인하고, Reload Montage를 재생하는 흐름을 시작한다.

막는 조건은 명확하다.

  1. 무기가 없음
  2. 무기 교체 중
  3. 이미 재장전 중
  4. 탄창이 이미 가득 참
  5. 예비 탄약이 없음

Reload Montage

Reload Montage는 Character Mesh와 Weapon Mesh 양쪽에서 재생된다.

Character Mesh는 손, 팔, 몸의 재장전 동작을, Weapon Mesh는 탄창, 슬라이드, 장전 부품 동작을 실행한다. 로컬 플레이어는 1인칭 Mesh와 1인칭 Montage를 사용한다.

다른 클라이언트가 보는 캐릭터는 3인칭 Mesh와 3인칭 Montage를 사용한다.

Mesh3P
ThirdPersonMontages
CurrentWeapon->GetMesh3P()
WeaponMontages

코드는 현재 Pawn이 로컬 조종 대상인지 확인한 뒤, 알맞은 Mesh와 Montage를 선택한다.

const bool bIsLocal = OwningPawn->IsLocallyControlled();


const TMap<FGameplayTag, FMontageData>& PlayerMontageMap = bIsLocal
    ? WeaponData->FirstPersonMontages
    : WeaponData->ThirdPersonMontages;

이후 현재 무기의 WeaponType으로 Reload Montage를 찾는다.

const FMontageData* PlayerMontageData =
    PlayerMontageMap.Find(CurrentWeapon->WeaponType);

무기 자체의 Reload Montage는 1P/3P에 따라 다른 Data Map을 쓰지는 않는다. 대신 실제 재생 대상 Mesh만 달라진다.

USkeletalMeshComponent* WeaponMesh =
    bIsLocal ? CurrentWeapon->GetMesh1P() : CurrentWeapon->GetMesh3P();

Reload RPC

재장전 몽타주도 무기 교체처럼 네트워크에 전파된다.

Initiate_ReloadWeapon
-> Local_ReloadWeapon
-> Server_ReloadWeapon
-> Multicast_ReloadWeapon
-> Local_ReloadWeapon

로컬 플레이어는 입력 즉시 자기 화면에서 재장전 몽타주를 본다. 서버는 Server_ReloadWeapon을 받고, Multicast_ReloadWeapon으로 다른 클라이언트에게 재장전 몽타주 재생을 전파한다.

이 단계에서도 아직 실제 탄약 수치는 바뀌지 않는다. Reload 입력과 Reload Montage 재생은 "재장전을 시작했다"는 의미이고, 탄약 이동은 AnimNotify 시점에 처리된다.

Reload Notify

이번 섹션에서 가장 중요한 부분은 Reload Notify다.

재장전 입력 순간에 탄약을 채우면 애니메이션과 수치가 맞지 않는다. 탄창을 빼기도 전에 HUD 숫자가 가득 차면 어색하고, 조작 감각도 흐려진다.

그래서 Reload Montage 안에 ReloadWeapon AnimNotify를 넣고, 탄창이 실제로 들어가는 시점에 함수를 호출한다.

Reload Montage
-> AnimNotify_ReloadWeapon
-> AnimBP Event Graph
-> Try Get Pawn Owner
-> Notify_ReloadWeapon Interface Message
-> AShooterCharacter::Notify_ReloadWeapon_Implementation
-> UCombatComponent::Notify_ReloadWeapon

AnimNotify는 애니메이션 자산의 특정 프레임에 찍히는 이벤트다.

C++은 "언제 재장전이 완료되는지"를 시간으로 추측하지 않고, 애니메이션이 직접 알려주는 프레임을 기준으로 실제 Reload 계산을 수행한다.

실제 탄약 이동

실제 탄약 계산은 Notify_ReloadWeapon에서 처리된다. 계산 자체는 단순하지만, 서버 권위로 처리된다는 점이 중요하다.

const int32 EmptySpace = CurrentWeapon->MagCapacity - CurrentWeapon->Ammo;
const int32 AmountToRefill = FMath::Min(EmptySpace, CurrentReserveAmmo);

CurrentWeapon->Ammo += AmountToRefill;
ReserveAmmo[CurrentWeapon->WeaponType] = CurrentReserveAmmo - AmountToRefill;
CurrentReserveAmmo = ReserveAmmo[CurrentWeapon->WeaponType];

예를 들어 탄창 용량이 10이고 현재 4발, 예비 탄약이 10발이면 다음처럼 계산된다.

MagCapacity = 10
Ammo = 4
CurrentReserveAmmo = 10

EmptySpace = 6
AmountToRefill = min(6, 10) = 6

Ammo = 10
CurrentReserveAmmo = 4

예비 탄약이 부족한 경우도 같은 계산으로 처리된다.

MagCapacity = 10
Ammo = 2
CurrentReserveAmmo = 3

EmptySpace = 8
AmountToRefill = min(8, 3) = 3

Ammo = 5
CurrentReserveAmmo = 0

이 방식은 탄창을 항상 꽉 채울 수 있다는 가정에 묶이지 않는다. 남은 예비 탄약만큼만 탄창에 들어간다.

Client Reload

서버에서 탄약 계산이 끝나면 로컬 클라이언트의 HUD도 최종 값으로 맞춰야 한다.

Client_ReloadWeapon(CurrentWeapon->Ammo, CurrentReserveAmmo);

클라이언트는 서버가 내려준 값을 받아 현재 무기의 AmmoCurrentReserveAmmo를 갱신하고, UI 델리게이트를 브로드캐스트한다.

CurrentWeapon->Ammo = NewWeaponAmmo;
CurrentReserveAmmo = NewReserveAmmo;

OnAmmoCounterChanged.Broadcast(...);
OnCurrentReserveAmmoChanged.Broadcast(...);

여기서 UI가 직접 탄약을 계산하지 않는 점이 중요하다. UI는 CombatComponent가 보내주는 결과만 표시한다. 탄약 이동의 정답은 서버가 계산하고, 클라이언트는 그 값을 받아 화면을 맞춘다.

Automatic Reload

자동 재장전은 두 곳에서 처리된다.

첫 번째는 발사 타이머가 끝났을 때다.

if (CurrentWeapon->Ammo == 0 &&
    CurrentReserveAmmo > 0 &&
    OwningPawn->IsLocallyControlled())
{
    Local_ReloadWeapon();
    Server_ReloadWeapon();
    return;
}

마지막 탄을 쏘고 FireTime이 끝났을 때, 탄창이 비었고 예비 탄약이 남아 있으면 자동으로 Reload Montage를 시작한다.

두 번째는 무기 교체 직후다.

if (CurrentWeapon->Ammo == 0 &&
    CurrentReserveAmmo > 0 &&
    OwningPawn->IsLocallyControlled())
{
    Local_ReloadWeapon();
    Server_ReloadWeapon();
}

빈 총으로 교체했는데 예비 탄약이 있다면, 플레이어가 R 키를 누르지 않아도 자동으로 재장전된다.

이 기능은 필수는 아니지만 조작감에는 꽤 영향을 준다.

플레이어가 "총알이 없는데 예비 탄약은 있다"는 상황에서 자연스럽게 다음 행동으로 이어지기 때문이다.

Listen Server와 Sequence

발사 탄약은 클라이언트 사이드 예측을 사용한다. 일반 클라이언트는 먼저 로컬에서 탄약을 줄이고, 서버 응답이 오면 Sequence를 이용해 아직 서버가 모르는 예측 발사 수를 다시 반영한다.

하지만 Listen Server의 호스트는 다르다. 호스트는 로컬 플레이어이면서 동시에 서버 권위를 가진다. 네트워크 지연 때문에 탄약을 예측할 필요가 없다.

그래서 Sequence는 권위가 없는 로컬 클라이언트에서만 증가시킨다.

if (OwningPawn->IsLocallyControlled())
{
    Ammo = FMath::Clamp(Ammo - 1, 0, MagCapacity);

    if (!OwningPawn->HasAuthority())
    {
        ++Sequence;
    }
}

Rep_Fire에서도 같은 조건을 둔다.

if (OwningPawn->IsLocallyControlled() && !OwningPawn->HasAuthority())
{
    Ammo = AuthAmmo;
    --Sequence;
    Ammo -= Sequence;
}

이렇게 해야 Listen Server에서 서버 권위 탄약과 로컬 예측 보정이 중복으로 섞이지 않는다.

Add Ammo Interface

Reload가 작동하려면 예비 탄약을 늘릴 방법도 필요하다. 이번 섹션에서는 PlayerInterfaceAddAmmo를 추가해서 외부 시스템이 탄약을 지급할 수 있게 만든다.

UFUNCTION(BlueprintNativeEvent, BlueprintCallable)
void AddAmmo(const FGameplayTag& WeaponType, int32 AmmoAmount);

여기서 BlueprintCallable은 Blueprint에서 호출하기 위해 필요하다.

Ammo Pickup은 Blueprint Actor로 만들기 때문에, Overlap이 발생했을 때 OtherActor에게 AddAmmo Interface Message를 보낼 수 있어야 한다.

BlueprintNativeEvent는 C++ 기본 구현을 두기 위해 사용한다.

void AAShooterCharacter::AddAmmo_Implementation(
    const FGameplayTag& WeaponType,
    int32 AmmoAmount)
{
    if (HasAuthority() && IsValid(Combat))
    {
        Combat->AddAmmo(WeaponType, AmmoAmount);
    }
}

Pickup BP는 CombatComponent를 알 필요가 없다.

Pickup이 아는 것
    WeaponType
    AmmoAmount
    OtherActor에게 AddAmmo 메시지를 보낸다

Pickup이 모르는 것
    CombatComponent
    ReserveAmmo TMap
    CurrentReserveAmmo
    HUD 갱신 방식

이 구조 덕분에 Pickup, 보상 시스템, 증강 효과 같은 외부 시스템이 모두 같은 인터페이스로 탄약을 지급할 수 있다.

이미지 첨부 추천
BP_AmmoPickup의 Overlap 그래프. OtherActor -> AddAmmo Interface Message 호출 부분을 보여주면 인터페이스 구조가 직관적으로 보인다.

CombatComponent AddAmmo

실제 탄약 추가는 CombatComponent에서 처리한다.

void UCombatComponent::AddAmmo(
    const FGameplayTag& WeaponType,
    int32 AmmoAmount)
{
    if (!GetOwner()->HasAuthority()) return;

    const int32 NewAmmo = ReserveAmmo.Contains(WeaponType)
        ? ReserveAmmo.FindChecked(WeaponType) + AmmoAmount
        : AmmoAmount;

    ReserveAmmo.Add(WeaponType, NewAmmo);
}

이미 가진 탄약 타입이면 기존 값에 더하고, 아직 없는 탄약 타입이면 새로 추가한다. 이 처리를 해두면 나중에 시작 무기에 없는 탄약을 줍는 상황도 대응할 수 있다.

현재 들고 있는 무기와 같은 타입의 탄약을 얻었다면 HUD도 바로 갱신한다.

if (CurrentWeapon->WeaponType.MatchesTagExact(WeaponType))
{
    CurrentReserveAmmo = NewAmmo;

    OnAmmoCounterChanged.Broadcast(...);
    OnCurrentReserveAmmoChanged.Broadcast(...);
}

반대로 권총을 들고 있는데 라이플 탄약을 먹었다면, ReserveAmmo 맵은 갱신되지만 현재 HUD는 바뀌지 않는다. 현재 HUD는 현재 무기 기준으로만 표시되기 때문이다.

Ammo Pickup

Ammo Pickup은 C++로 무겁게 만들지 않고 Blueprint Actor로 구성한다.

기본 클래스는 BP_AmmoPickup이고, 자식 Blueprint로 탄약 종류를 나눈다.

BP_AmmoPickup
    공통 베이스

BP_AmmoPickup_Pistol
    Weapon.Type.Pistol
    AmmoAmount = 10

BP_AmmoPickup_Rifle
    Weapon.Type.Rifle
    AmmoAmount = 20

사용된 변수들

필요한 변수는 다음 정도다.

WeaponType
    어떤 무기 탄약인지

AmmoAmount
    얼마나 줄 것인지

bIsActive
    현재 먹을 수 있는 상태인지

RespawnInterval
    몇 초 뒤 다시 활성화할지

Begin Overlap

Overlap이 발생하면 Pickup은 자신을 비활성화하고, 겹친 Actor에게 AddAmmo 메시지를 보낸다.

OnComponentBeginOverlap
-> bIsActive 확인
-> Mesh 숨김
-> Collision 비활성화
-> Pickup Sound 재생
-> OtherActor.AddAmmo(WeaponType, AmmoAmount)
-> Respawn Timer 시작

Respawn Timer가 끝나면 다시 활성화한다.

Respawn Event

Overlap된 Actor가 AddAmmo 인터페이스를 구현하고 있으면 메시지를 보내고, 실제 서버 권위 체크와 탄약 갱신은 Character와 CombatComponent가 처리한다.

전체흐름도

전체 흐름 정리

이번 섹션의 최종 흐름은 다음과 같다.

Reload 입력
-> 조건 확인
-> Local_ReloadWeapon
-> 1P Reload Montage 재생
-> Server_ReloadWeapon
-> Multicast_ReloadWeapon
-> 다른 클라이언트 3P Reload Montage 재생
-> AnimNotify_ReloadWeapon
-> CombatComponent::Notify_ReloadWeapon
-> 서버에서 Ammo / ReserveAmmo 계산
-> Client_ReloadWeapon
-> HUD 갱신

Ammo Pickup까지 포함하면 흐름은 이렇게 이어진다.

Pickup Overlap
-> OtherActor.AddAmmo Interface Message
-> AShooterCharacter::AddAmmo_Implementation
-> CombatComponent::AddAmmo
-> ReserveAmmo 갱신
-> 현재 무기 타입이면 HUD 갱신
-> 빈 총이면 자동 Reload

함수 역할을 나누면 더 단순하다.

Initiate_ReloadWeapon
    재장전을 시작할 수 있는지 검사한다.

Local_ReloadWeapon
    현재 머신 기준으로 1P 또는 3P Reload Montage를 재생한다.

Server_ReloadWeapon
    서버에 재장전 시작을 알린다.

Multicast_ReloadWeapon
    다른 클라이언트들에게 재장전 몽타주 재생을 전파한다.

Notify_ReloadWeapon
    AnimNotify 시점에 실제 탄약 계산을 수행한다.

Client_ReloadWeapon
    서버가 계산한 탄약 값을 로컬 HUD에 반영한다.

AddAmmo
    외부 시스템이 탄약을 지급할 수 있는 입구를 제공한다.

입력은 Montage를 시작하고, Montage는 Notify로 정확한 수치 변경 시점을 알려주며, 서버는 탄약 계산의 최종 권위를 가진다. 여기에 Interface 기반 AddAmmo를 붙이면, 탄약 픽업 같은 외부 시스템도 CombatComponent 내부 구조를 몰라도 자연스럽게 전투 시스템에 연결된다.

profile
공부 정리 노트

0개의 댓글