1년 뒤 시선에서 시작하는 FPS 설계/FPS 보일러플레이트 #1

공팡팡·2026년 7월 24일

ShooterRevisited

목록 보기
1/7

들어가며

지금까지 두번의 FPS프로젝트를 진행한바가 있다.

하나는 첫번째로 만든 작이자, 가장 기본 형태의 멀티플레이 슈터장르고, 두번째는 TopDown장르의 덕르코프를 카피한 슈터장르다.

시간이 지나면서 설계를 바라보는 태도와 시각이 달라졌는데, 그럼 지금은 과연 어떤 시선으로 FPS설계가 가능할까하여 Udemy의 Stephen강사의 "Multiplayer Shooter" 강의를 곁들어 재설계 및 해부해보는 시간을 가져보려고 한다.

이번 설계의 핵심은 로컬 플레이어가 보는 1인칭 표현과 다른 플레이어가 보는 3인칭 표현을 분리하고, 전투 로직을 Character에 몰아넣지 않고 CombatComponent, Weapon, DataAsset, Interface로 나누는 것이다.

의존성을 최대한 덜어낸 설계.

개발자가 지향하는 설계를 최대한 해보려고 한다.

전에 작성되었던 프로젝트의 코드를 뜯어보는데 다소 투박한 흔적이 여기저기 남아있다.
https://velog.io/@pass1198/Project-Acero-14-Shotgun
위 링크는 해당 AceroProject를 정리한 내용이다.

지향하는 바는 다음과 같다.

  1. 로컬 플레이어가 보는 1인칭 팔/무기와
    다른 플레이어가 보는 3인칭 몸/무기를 분리한다.

  2. Character에 전투 로직을 직접 넣지 않고
    CombatComponent로 분리한다.

  3. 무기 타입은 enum보다 GameplayTag 기반으로 다룬다. 또한 무기 부착 위치 같은 정보는 코드가 아니라 DataAsset에 둔다.

  4. WeaponCharacter 구체 타입을 직접 알지 않고
    Interface를 통해 필요한 정보만 요청한다.

1인칭/3인칭 Mesh 분리

왜 메시를 두 개로 나누는가

멀티플레이 FPS에서는 내가 보는 캐릭터와 남이 보는 캐릭터가 다르다.

내 화면에서는 보통 1인칭 팔, 1인칭 무기, 카메라 기준으로 자연스러운 총 위치가 필요하다. 반대로 다른 플레이어에게는 전신 캐릭터와 3인칭 무기가 월드 기준 애니메이션으로 보여야 한다.

ShooterCharacter
├─ Mesh1P       // 내가 보는 팔
├─ Camera
├─ SpringArm
└─ GetMesh()    // 다른 플레이어가 보는 전신 메시

Owner 기준 Visibility

1인칭 팔은 소유자만 봐야 한다.

Mesh1P->bOnlyOwnerSee = true;
Mesh1P->bOwnerNoSee = false;

반대로 전신 메시인 GetMesh()는 소유자는 보지 않고, 다른 플레이어만 봐야 한다.

GetMesh()->bOnlyOwnerSee = false;
GetMesh()->bOwnerNoSee = true;

이 설계는 나중에 무기에도 그대로 적용된다.

Weapon Mesh1P -> 로컬 플레이어용
Weapon Mesh3P -> 다른 플레이어용

즉 Character와 Weapon 모두 1P/3P 표현을 따로 가진다.

1인칭 시점

3인칭 시점

그래서 캐릭터는 기본적으로 두 개의 시각 표현을 가진다.

에디터 내부

입력 구조

기본 이동 입력

초기 이동 입력은 ShooterPlayerController에서 처리했다. 너무나 당연한 파트이기도하고, 개선할 파트가 없어서 보일러플레이트식으로 설명만 진행하려고 한다.

Move
Look
Jump
Crouch

이들은 플레이어가 Pawn을 조종하는 기본 입력에 가깝다.

AddYawInput(InputAxisVector.X);
AddPitchInput(InputAxisVector.Y);

이동은 컨트롤러의 yaw만 사용한다.

const FRotator Rotation = GetControlRotation();
const FRotator YawRotation(0.f, Rotation.Yaw, 0.f);

Pitch를 버리는 이유는 명확하다. 플레이어가 위를 보고 있다고 해서 W 입력으로 하늘 방향으로 이동하면 안 된다. FPS 이동은 보통 수평면 기준이다.

전투 입력

전투 입력은 Character 쪽에 바인딩했다.

Fire
Aim
Reload
CycleWeapon

왜 바인딩을 Controller에서 안하고 Character에서 하는가? 라고 물을 수 있다.

PlayerController에서 해도 되고 Character에서 해도 된다.

하지만 CharacterCombatComponent를 직접 가지고 있으므로, 전투 입력은 Character에서 받아 CombatComponent로 넘긴다.

예를 들면 다음과 같다.

void AAShooterCharacter::Input_FireWeapon_Pressed()
{
    Combat->Initiate_FireWeapon_Pressed();
}

Character는 실제 전투 로직을 직접 처리하지 않는다. 단지 입력을 CombatComponent로 라우팅한다. 의존성을 줄일 수 있는 하나의 방법이다.

CombatComponent

Character의 Fat Class 최소화

FPS 캐릭터에 전투 로직을 전부 넣기 시작하면 금방 비대해진다.

최소한으로 필요한 Action들만 대충 짚어보자.

무기 장착
발사
장전
탄약
조준
무기 교체
인벤토리
현재 무기
애니메이션 상태
서버 RPC

이 모든 것을 Character가 들고 있으면 유지보수가 어려워진다.

그래서 전투 관련 상태와 행동은 UCombatComponent로 분리한다.

AceroProject에서도 CombatComponent를 활용하였다. 후술하겠지만, 여기서는 좀 더 많은 측면을 고려한다.

Combat = CreateDefaultSubobject<UCombatComponent>("Combat");
Combat->SetIsReplicated(true);

CombatComponentreplicated 컴포넌트다.

이후 무기, 인벤토리, 현재 장착 무기 같은 네트워크 상태를 이 컴포넌트가 관리한다.

전투 입력 진입점

초기에는 다음과 같은 public 함수들을 만든다.

void Initiate_CycleWeapon();
void Initiate_FireWeapon_Pressed();
void Initiate_FireWeapon_Released();
void Initiate_ReloadWeapon();
void Initiate_Aim_Pressed();
void Initiate_Aim_Released();

GameplayTag 기반 WeaponType

enum 대신 GameplayTag

무기 타입은 enum이 아니라 GameplayTag로 정의한다.

이 프로젝트에서 GAS는 사용하지 않는데 왜 Tag를 사용하냐 물어볼 수 있다.

먼저 GameplayTag는 GAS전용이 아니라 Unreal 전반에서 사용되는 식별시스템이다.

Enum을 사용하면 타입이 늘어날수록 코드가 Switch를 통해 다양한 방법으로 표기하여야 한다.

전 프로젝트인 Acero에서도 이런 Enum방식을 사용하였으며 대략적인 코드는 다음과 같다.

switch (WeaponType)
{
case EWeaponType::Pistol:
    return PistolGripPoint;
case EWeaponType::Rifle:
    return RifleGripPoint;
}

초기에는 간단하지만, 무기 타입이 늘어날수록 코드 분기가 커진다.

그럼 Tag는 어떨까.

UE_DEFINE_GAMEPLAY_TAG_COMMENT(
    TAG_WeaponType_Rifle,
    "Weapon.Type.Rifle",
    "Rifle Weapon Type"
);

GameplayTag를 쓰면 데이터 중심으로 확장하기 쉽다.

Weapon.Type.Rifle
Weapon.Type.Pistol
Weapon.Type.Shotgun
Weapon.Type.Sniper

무기 클래스는 자신의 타입을 FGameplayTag로 가진다.

UPROPERTY(EditAnywhere, Category = "FPS|WeaponType")
FGameplayTag WeaponType;

태그는 계층 구조를 가질 수 있고, DataAsset이나 Gameplay 시스템과도 잘 맞는다.

WeaponData

태그 기반 GripPoint 매핑

무기마다 다른 GripPoint를 가져야하는것은 당연하다.

Pistol과 Rifle을 잡는 Socket이 다르기 때문이다.

무기 부착 위치는 코드에 직접 박지 않고 UWeaponData에 둔다.

UCLASS()
class FPS_API UWeaponData : public UDataAsset
{
    GENERATED_BODY()

public:
    UPROPERTY(EditDefaultsOnly, Category = "FPS|WeaponData|Weapons")
    TMap<FGameplayTag, FName> GripPoints;
};

이 구조는 다음 의미를 가진다.

무기는 자신의 WeaponType을 가지고 있고, Character는 WeaponData를 통해 그 타입에 맞는 attach point를 반환한다.

return Combat->WeaponData->GripPoints.FindChecked(WeaponType);

개발 중에는 FindChecked를 사용해서 데이터 누락을 빠르게 터뜨린다. 실제 프로젝트에서는 fallback 처리도 고민할 수 있다.

if (const FName* AttachPoint = GripPoints.Find(WeaponType))
{
    return *AttachPoint;
}
return NAME_None;

PlayerInterface

Weapon이 Character를 직접 알지 않게 하기

무기를 캐릭터 손에 붙이려면 무기는 다음 정보를 알아야 한다.

1P mesh
3P mesh
무기 타입에 맞는 attach socket name

가장 쉬운 방법은 Weapon에서 Character로 cast하는 것이다.

AAShooterCharacter* Character = Cast<AAShooterCharacter>(GetOwner());

하지만 이렇게 하면 AWeaponAAShooterCharacter에 강하게 묶인다.

Interface로 필요한 정보만 요청

강의에서는 IPlayerInterface를 만든다.

UFUNCTION(BlueprintNativeEvent, BlueprintCallable)
FName GetWeaponAttachPoint(const FGameplayTag& WeaponType) const;

UFUNCTION(BlueprintNativeEvent, BlueprintCallable)
USkeletalMeshComponent* GetMesh1P() const;

UFUNCTION(BlueprintNativeEvent, BlueprintCallable)
USkeletalMeshComponent* GetMesh3P() const;

Character는 이를 구현한다.

USkeletalMeshComponent* AAShooterCharacter::GetMesh1P_Implementation() const
{
    return Mesh1P;
}

USkeletalMeshComponent* AAShooterCharacter::GetMesh3P_Implementation() const
{
    return GetMesh();
}

이제 Weapon은 Character 구체 타입을 몰라도 된다.

if (!OwningPawn->Implements<UPlayerInterface>()) return;

필요한 정보는 interface로 요청한다.

const FName AttachPoint =
    IPlayerInterface::Execute_GetWeaponAttachPoint(OwningPawn, WeaponType);

이 구간에서 가장 의미 있는 설계 포인트다.

Weapon Actor

무기도 1P/3P 메시를 가진다

Character가 1P/3P 메시를 나눴듯이 Weapon도 1P/3P 메시를 가진다.

Mesh1P = CreateDefaultSubobject<USkeletalMeshComponent>("Mesh1P");
Mesh3P = CreateDefaultSubobject<USkeletalMeshComponent>("Mesh3P");

무기는 replicated actor다.

bReplicates = true;
bNetUseOwnerRelevancy = true;

bNetUseOwnerRelevancy는 owner의 relevancy를 따라가게 하는 설정이다. 무기는 캐릭터와 강하게 연결된 actor이므로 owner relevancy를 사용하는 것이 자연스럽다.

로컬 여부에 따른 무기 Visibility

무기 attach 시점에 소유 Pawn이 locally controlled인지 확인한다.

if (OwningPawn->IsLocallyControlled())
{
    Mesh1P->SetHiddenInGame(false);
    Mesh3P->SetHiddenInGame(true);
}
else
{
    Mesh1P->SetHiddenInGame(true);
    Mesh3P->SetHiddenInGame(false);
}

즉 로컬 플레이어는 1P weapon만 보고, 다른 플레이어는 3P weapon만 본다.

서버 권한 기반 Weapon Spawn

CombatComponent가 무기 Spawn 담당

무기 spawn은 CombatComponent의 책임이다.

AWeapon* UCombatComponent::SpawnWeapon(TSubclassOf<AWeapon> WeaponClass) const
{
    AActor* OwningActor = GetOwner();
    if (!IsValid(OwningActor)) return nullptr;
    if (OwningActor->GetLocalRole() < ROLE_Authority) return nullptr;

    FActorSpawnParameters SpawnInfo;
    SpawnInfo.Instigator = Cast<APawn>(OwningActor);
    SpawnInfo.Owner = OwningActor;
    SpawnInfo.SpawnCollisionHandlingOverride =
        ESpawnActorCollisionHandlingMethod::AlwaysSpawn;

    return GetWorld()->SpawnActor<AWeapon>(WeaponClass, SpawnInfo);
}

여기서 중요한 점은 authority 체크다.

if (OwningActor->GetLocalRole() < ROLE_Authority) return nullptr;

ROLE_Authority보다 작으면 서버 권한이 아니므로 spawn하지 않는다.

단순히 이렇게 써도 의미는 같다.

if (OwningActor->GetLocalRole() != ROLE_Authority) return nullptr;

위의 코드와 정확히 같은 방식이다.
Enum이 int타입의 열거형이라는 걸 잊지말자.

SpawnParameters

spawn할 때 owner와 instigator를 지정한다.

SpawnInfo.Instigator = Cast<APawn>(OwningActor);
SpawnInfo.Owner = OwningActor;

이 설정은 이후 Weapon이 자신의 owning pawn을 찾는 데 사용된다.

APawn* OwningPawn = GetInstigator();

Weapon Attachment

서버 Attach

서버에서 무기를 spawn하면 바로 붙인다.

AWeapon* Weapon = SpawnWeapon(WeaponClass);
Inventory.AddUnique(Weapon);

현재 장착 무기는 Equip()에서 attach한다.

void UCombatComponent::Equip(AWeapon* Weapon)
{
    CurrentWeapon = Weapon;
    CurrentWeapon->AttachToOwningPawn();
}

클라이언트 Attach

무기는 replicated actor이므로 서버에서 spawn하면 클라이언트에도 복제된다. 하지만 클라이언트에서는 spawn 직후 owner/instigator가 아직 준비되지 않았을 수 있다.

그래서 OnRep_Instigator()에서 다시 attach를 시도한다.

void AWeapon::OnRep_Instigator()
{
    Super::OnRep_Instigator();
    AttachToOwningPawn();
}

그리고 CurrentWeapon도 복제된다.

UPROPERTY(Transient, ReplicatedUsing = OnRep_CurrentWeapon)
TObjectPtr<AWeapon> CurrentWeapon;

클라이언트는 current weapon이 복제되면 다시 attach한다.

void UCombatComponent::OnRep_CurrentWeapon(AWeapon* LastWeapon)
{
    if (!IsValid(CurrentWeapon)) return;
    CurrentWeapon->AttachToOwningPawn();
}

서버에서는 즉시 attach할 수 있지만, 클라이언트에서는 actor/owner/instigator/current weapon 복제 순서가 다를 수 있다. 이 부분이 멀티플레이에서 중요하다.

Inventory와 CurrentWeapon

DefaultWeaponClasses

초기에는 단일 무기만 spawn했다.

TSubclassOf<AWeapon> DefaultWeaponClass;

이후 inventory 구조로 바뀌면서 배열이 된다.

UPROPERTY(EditDefaultsOnly, Category = "FPS|Weapon")
TArray<TSubclassOf<AWeapon>> DefaultWeaponClasses;

BP에서는 여기에 기본 무기 BP들을 넣는다.

Inventory 복제

spawn된 무기들은 inventory 배열에 들어간다.

UPROPERTY(Transient, Replicated)
TArray<AWeapon*> Inventory;

그리고 replication 설정에 등록한다.

DOREPLIFETIME(UCombatComponent, Inventory);

CurrentWeapon 복제

현재 장착 무기는 따로 관리한다.

UPROPERTY(Transient, ReplicatedUsing = OnRep_CurrentWeapon)
TObjectPtr<AWeapon> CurrentWeapon;

Inventory는 "내가 가진 무기 목록"이고, CurrentWeapon은 "현재 손에 든 무기"다.

이 둘을 분리하는 건 이후 무기 교체, UI, ammo 표시, reload 상태 처리에서 중요해진다.

Character와 Inventory 생명주기

Character 파괴 시 Inventory 정리

Character가 사라질 때 spawn한 무기도 정리해야 한다.

void AAShooterCharacter::BeginDestroy()
{
    Super::BeginDestroy();

    if (IsValid(Combat))
    {
        Combat->DestroyInventory();
    }
}

CombatComponent는 inventory를 순회하며 무기를 파괴한다.

for (AWeapon* Weapon : Inventory)
{
    if (IsValid(Weapon))
    {
        Weapon->Destroy();
    }
}

아직 단순하지만, Character가 사라질 때 장비도 같이 정리한다는 lifecycle 관리가 들어가기 시작한 것이다.

마무리

아직 총을 쏘거나 장전하거나 무기를 교체하는 단계까지는 가지 않았다. 하지만 이 시점에서 이미 설계 방향은 꽤 분명하다.

초반 코드만 보면 보일러플레이트가 많지만, GameplayTag + DataAsset + Interface + Replicated Component 조합이 나오면서 단순 튜토리얼식 FPS보다는 확장 가능한 멀티플레이 슈터 구조로 방향을 잡고 있다는 점이 보인다.

profile
공부 정리 노트

0개의 댓글