Cycling의 핵심은 "다음 무기로 바꾼다"가 아니라, 현재 무기라는 기준점이 바뀔 때 애니메이션, 네트워크, UI가 같은 타이밍으로 따라오게 만드는 것에 있다.
FPS에서 무기 교체는 내부 상태, 화면에 보이는 손 애니메이션, 다른 클라이언트가 보는 3인칭 동작, 그리고 HUD까지 함께 움직이는 작은 파이프라인에 가깝다.

무기 하나만 들고 있을 때는 Ammo와 MagCapacity만으로도 HUD를 구성할 수 있다. 하지만 여러 무기를 순환하기 시작하면, 각 무기마다 별도의 예비 탄약을 기억해야 한다.
예를 들어 권총과 라이플을 들고 있다면 현재 장착한 무기의 탄창만 바뀌는 것이 아니다.
Pistol
Ammo: 8
ReserveAmmo: 40
Rifle
Ammo: 25
ReserveAmmo: 90
이 값들은 서로 독립적으로 유지되어야 한다. 그래서 CombatComponent는 무기 타입을 키로 삼는 ReserveAmmo 맵을 가진다.
TMap<FGameplayTag, int32> ReserveAmmo;
여기서 enum이 아니라 GameplayTag를 키로 사용하는 이유는 이전 무기 설계와 이어진다.
무기 타입은 단순히 Rifle, Pistol 같은 분기값으로 끝나지 않고, DataAsset의 Montage, Reticle, Ammo UI, Attach Point를 찾는 공통 식별자로 사용된다. 즉 태그는 분기를 위한 값이라기보다 무기 관련 데이터를 연결하는 주소에 가깝다.

무기를 스폰할 때는 각 무기의 시작 예비 탄약을 맵에 등록한다.
ReserveAmmo.Add(Weapon->WeaponType, Weapon->StartingCarriedAmmo);
그리고 현재 무기가 바뀌면 해당 무기 타입의 예비 탄약을 다시 꺼내온다.
CurrentReserveAmmo = ReserveAmmo.FindChecked(CurrentWeapon->WeaponType);
이렇게 해두면 UI는 현재 무기가 무엇인지 직접 판단하지 않아도 된다.
CombatComponent가 현재 무기를 기준으로 필요한 값을 골라서 브로드캐스트하고, UI는 그 결과만 표시한다.
Rifle의 예시 Ammo Setting
Reserve Ammo UI는 현재 무기의 탄창 수와 예비 탄약을 같이 보여준다.
8 / 40
여기서 중요한 점은 위젯이 직접 무기를 뒤지지 않는다는 것이다.
위젯은 OnCurrentReserveAmmoChanged, OnRoundFired, OnWeaponFirstReplicated 같은 이벤트를 구독하고, CombatComponent가 보내주는 값만 반영한다.
void UReserveAmmo::OnCurrentReserveAmmoChanged(
int32 RoundsInReserve,
int32 RoundsInWeapon,
UMaterialInterface* WeaponIconMaterial)
{
Text_Ammo->SetText(
FText::Format(FText::FromString("{0}/{1}"), RoundsInWeapon, RoundsInReserve));
}
이 구조는 Pawn이 바뀌는 상황에서도 중요하다. PlayerController는 유지되지만 Pawn은 재생성될 수 있고, 그때 UI는 기존 Pawn의 CombatComponent에 묶여 있으면 안 된다.
그래서 OnPossessedPawnChanged에서 이전 Pawn의 델리게이트를 해제하고, 새 Pawn의 CombatComponent에 다시 바인딩한다.
처음에는 약간 번거롭게 느껴지지만, 멀티플레이어 FPS에서 리스폰이나 캐릭터 교체가 들어오면 이 구조가 없을 때 UI가 쉽게 꼬인다.
UI가 플레이어 입력 주체인 Controller에 붙어 있고, 실제 전투 상태는 Pawn/CombatComponent에 있으므로, 그 사이를 다시 연결하는 과정이 필요하다.

무기 교체가 들어가면 탄약 숫자만 바뀌는 것으로는 충분하지 않다. 플레이어는 지금 어떤 무기를 들고 있는지도 HUD에서 바로 확인할 수 있어야 한다.
이를 위해 각 Weapon은 자기 아이콘 머티리얼을 가진다.
UPROPERTY(EditDefaultsOnly, Category = "FPS|Icon")
TObjectPtr<UMaterialInterface> WeaponIcon;
Reserve Ammo Widget은 현재 무기가 바뀔 때 이 아이콘 머티리얼도 함께 받는다.
FSlateBrush Brush;
Brush.SetResourceObject(WeaponIconMaterial);
Image_WeaponIcon->SetBrush(Brush);
여기서 아이콘을 UI가 직접 선택하지 않는 점이 중요하다.
UI가 if Rifle, if Pistol 같은 분기를 갖기 시작하면 무기가 추가될 때마다 UI도 수정해야 한다.
반대로 Weapon이 자기 표현에 필요한 리소스를 들고 있고, CombatComponent가 현재 무기의 값을 전달하면 UI는 단순한 표시 계층으로 남을 수 있다.

무기 교체를 시작하기 전에 먼저 무기의 상태를 정의한다.
UENUM(BlueprintType)
enum class EWeaponStatus : uint8
{
Idle,
Firing,
Reloading,
Cycling,
Unequipped
};
무기 교체 입력은 Character에서 시작되지만, 실제 처리는 CombatComponent가 담당한다.
Input_CycleWeapon
-> CombatComponent::Initiate_CycleWeapon
Initiate_CycleWeapon의 역할은 교체를 바로 끝내는 것이 아니라, 교체를 시작할 수 있는지 확인하고 다음 무기 인덱스를 계산하는 것이다.
void UCombatComponent::Initiate_CycleWeapon()
{
if (!IsValid(CurrentWeapon)) return;
if (CurrentWeapon->WeaponStatus == EWeaponStatus::Cycling) return;
AdvanceWeaponIndex();
Local_CycleWeapon(Local_WeaponIndex);
}
여기서 AdvanceWeaponIndex는 배열의 끝에 도달하면 다시 처음으로 돌아가게 만든다.
Local_WeaponIndex = (Local_WeaponIndex + 1) % Inventory.Num();
이 % 연산 덕분에 인벤토리 크기가 2개든 5개든 같은 방식으로 순환할 수 있다.
0 -> 1 -> 2 -> 0 -> 1 ...
무기 교체에서 먼저 발생하는 것은 실제 교체가 아니라 Equip Montage 재생이다.
이때 로컬 플레이어는 1인칭 메시의 몽타주를 봐야 하고, 다른 클라이언트들은 3인칭 메시의 몽타주를 봐야 한다.
로컬 플레이어
Mesh1P + FirstPersonMontages
다른 클라이언트
Mesh3P + ThirdPersonMontages
Local_CycleWeapon은 이 둘을 같은 함수 안에서 분기한다.
const bool bIsLocal = IsValid(OwningPawn) && OwningPawn->IsLocallyControlled();
const TMap<FGameplayTag, FMontageData>& MontageMap = bIsLocal
? WeaponData->FirstPersonMontages
: WeaponData->ThirdPersonMontages;
그리고 다음 무기의 WeaponType으로 Equip Montage를 찾는다.
const FMontageData* MontageData = MontageMap.Find(NextWeapon->WeaponType);
여기서 중요한 점은 현재 들고 있는 무기의 몽타주가 아니라, 앞으로 들게 될 무기의 Equip Montage를 재생한다는 것이다.
권총에서 라이플로 바꾼다면 권총 교체 몽타주가 아니라 라이플을 장착하는 몽타주가 재생되어야 한다.

무기 교체 입력은 로컬에서 즉시 반응해야 한다. 그래서 로컬 플레이어는 먼저 자기 화면에서 1인칭 Equip Montage를 재생한다.
이후 서버에 교체 시작을 알리고, 서버는 다른 클라이언트들에게 3인칭 교체 애니메이션을 전파한다.
Local_CycleWeapon
-> Server_CycleWeapon
-> Multicast_CycleWeapon
-> 다른 클라이언트의 Local_CycleWeapon
여기서 Local_CycleWeapon이라는 이름이 약간 헷갈릴 수 있다. 이 함수는 항상 1인칭만 재생하는 함수가 아니라, 현재 머신에서 이 캐릭터가 로컬 조종 대상인지 판단하고 1P 또는 3P를 재생하는 공통 함수다.
내가 조종하는 Pawn
bIsLocal == true
1P Montage 재생
다른 플레이어의 Pawn
bIsLocal == false
3P Montage 재생
Multicast는 모든 머신에 도착하지만, 로컬 플레이어는 이미 1P 몽타주를 재생했다. 그래서 Multicast 쪽에서는 로컬 조종 Pawn을 제외하고 나머지에서만 Local_CycleWeapon을 호출한다.
if (!OwningPawn->IsLocallyControlled())
{
Local_WeaponIndex = WeaponIndex;
Local_CycleWeapon(WeaponIndex);
}
이렇게 해야 로컬 플레이어의 교체 애니메이션이 중복 재생되지 않는다.
무기 교체에서 가장 중요한 부분은 실제 CurrentWeapon을 바꾸는 타이밍이다.
입력 순간에 바로 무기를 바꾸면 다음과 같은 문제가 생길 수 있다.
화면에서는 아직 기존 무기를 내리는 중
내부 CurrentWeapon은 이미 새 무기
HUD는 새 무기 기준으로 먼저 바뀜
이러면 플레이어가 보는 애니메이션과 내부 상태가 맞지 않는다. 그래서 실제 교체는 Equip Montage 중간, 손과 무기가 화면 밖으로 내려간 순간에 처리한다.
이 타이밍은 AnimNotify가 담당한다.
Notify_CycleWeapon은 현재 계산된 Local_WeaponIndex를 기준으로 새 무기를 꺼내고, EquipWeapon을 호출한다.
void UCombatComponent::Notify_CycleWeapon()
{
AWeapon* NewWeapon = Inventory[Local_WeaponIndex];
if (IsValid(NewWeapon))
{
EquipWeapon(NewWeapon);
}
}

실제 교체는 SetCurrentWeapon에서 정리된다.
void UCombatComponent::SetCurrentWeapon(AWeapon* NewWeapon, AWeapon* LastWeapon)
{
if (IsValid(LocalLastWeapon))
{
LocalLastWeapon->DetachFromOwningPawn();
LocalLastWeapon->WeaponStatus = EWeaponStatus::Unequipped;
}
CurrentWeapon = NewWeapon;
CurrentWeapon->AttachToOwningPawn(OwningPawn);
}
이 함수는 이전 무기를 Pawn에서 떼어내고 숨긴 뒤, 새 무기를 1P/3P Mesh의 적절한 소켓에 붙인다.
서버에서는 현재 무기의 예비 탄약도 다시 맞춘다.
CurrentReserveAmmo = ReserveAmmo.FindChecked(CurrentWeapon->WeaponType);
CurrentWeapon은 복제되는 값이므로, 클라이언트에서는 OnRep_CurrentWeapon을 통해 같은 정리가 한 번 더 이루어진다.
이때도 단순히 attach만 하는 것이 아니라 SetCurrentWeapon을 호출해서 이전 무기 detach와 새 무기 attach가 같은 규칙으로 처리되도록 한다.
AnimNotify에서 실제 무기를 바꾸더라도, 교체 전체가 끝난 것은 아니다. Equip Montage가 끝나야 다시 발사하거나 재장전할 수 있다.
그래서 로컬 플레이어는 Montage의 BlendOut 이벤트를 구독한다.
AnimInstance->OnMontageBlendingOut.AddDynamic(
this,
&ThisClass::BlendOut_CycleWeapon);
BlendOut 시점에는 무기 상태를 다시 Idle로 돌리고, 새 무기 기준으로 UI를 갱신한다.
CurrentWeapon->WeaponStatus = EWeaponStatus::Idle;
OnReticleChanged.Broadcast(...);
OnAmmoCounterChanged.Broadcast(...);
OnCurrentReserveAmmoChanged.Broadcast(...);
이 타이밍이 중요한 이유는 UI가 너무 빨리 바뀌면 애니메이션과 화면 정보가 분리되어 보이기 때문이다.
실제 무기는 Notify 시점에 바뀌지만, 교체가 끝난 뒤에 Reticle, Ammo Counter, Reserve Ammo, Weapon Icon까지 한 번에 갱신하면 플레이어 입장에서는 "새 무기를 꺼냈고, 그 무기의 HUD가 나타났다"는 흐름으로 자연스럽게 받아들일 수 있다.
또 하나의 디테일은 자동사격이다.
if (bTriggerPressed && CurrentWeapon->FireType == EFireType::Auto && CurrentWeapon->Ammo > 0)
{
Local_FireWeapon();
}
무기 교체 중에는 Cycling 상태라 발사가 막힌다.
하지만 플레이어가 마우스를 계속 누르고 있었다면, 교체가 끝난 뒤 자동사격 무기는 다시 발사를 이어갈 수 있다.
이 처리는 조작감을 부드럽게 만드는 작은 부분이다.