포스트

무기 시스템 컴포넌트화와 DataAsset 분리

무기 추가에 코드를 안 고치는 구조

무기가 둘이 되는 순간 Pawn의 발사 코드는 분기로 갈라진다. 로직은 컴포넌트로 캡슐화하고 펠릿·스프레드·반동 같은 값은 DataAsset으로 빼내, 무기 추가가 에셋 하나로 끝나게 만들었다.

무기 시스템 컴포넌트화와 DataAsset 분리

무기가 하나일 때는 Pawn 안에 발사 로직을 넣어도 굴러간다. 두 번째 무기를 붙이는 순간 그 코드는 if (무기종류 == 샷건)으로 갈라지기 시작한다. 이 글에서는 Pawn에 몰려 있던 무기 로직을 컴포넌트로 캡슐화하고 무기 파라미터를 DataAsset으로 빼내는 작업을 이야기하려 한다. 구현은 UVOIDWeaponComponent 분리, UVOIDWeaponConfig DataAsset화, 누적·회복 모델로 만든 반동, 1/2 키 무기 스왑이고, 트러블슈팅은 디버거에서 쓰레기값이 찍힌 dangling 포인터 문제다.

기술 구현 1 — 무기 로직 캡슐화 UVOIDWeaponComponent

왜 분리했나

기존에는 AVOIDPlayerCharacterCurrentWeapon 멤버부터 발사 로직, 반동 누적, 사운드 재생까지 전부 들고 있었다. Pawn이 계속 비대해지고, 나중에 무기를 다른 액터(터렛/NPC)에 붙이려면 코드를 통째로 복제해야 하는 구조다.

그래서 무기 관련 책임을 UActorComponent로 빼고, Pawn은 입력을 받아 컴포넌트에 위임만 하도록 바꿨다.

인터페이스

1
2
3
4
5
6
7
8
9
class UVOIDWeaponComponent : public UActorComponent
{
    void EquipWeapon(UVOIDWeaponConfig* NewWeapon);
    bool TryFire(const FVector& MuzzleLoc, const FRotator& AimRot, AActor* DamageInstigator, float SpreadMul);
    void TickRecoil(float DeltaTime, float& OutPitchDelta, float& OutYawDelta);

    FOnWeaponEquipped OnWeaponEquipped;
    FOnWeaponFired   OnWeaponFired;
};

Dangling 가드

Interval=532053344 같은 쓰레기값이 찍힌 디버거 사례를 보고 추가:

1
2
3
4
5
6
UVOIDWeaponConfig* Weapon = IsValid(CurrentWeapon) ? CurrentWeapon : nullptr;
if (!Weapon && CurrentWeapon)
{
    UE_LOG(LogTemp, Warning, TEXT("[Weapon] CurrentWeapon is dangling — clearing"));
    CurrentWeapon = nullptr;
}

GC 직후에도 TObjectPtr가 한 프레임 동안 dangling일 수 있어서 IsValid 체크를 강제했다. 가드에 걸리면 폴백(25dmg/단발)으로 동작하니 크래시 없이 넘어간다.



기술 구현 2 — 무기 파라미터 DataAsset화 UVOIDWeaponConfig

왜 DataAsset 인가

샷건과 라이플은 EVOIDWeaponClass만 다른 게 아니라 모든 파라미터가 다르다. BP 변수로 흩어놓기보다 별도 DataAsset 인스턴스로 두는 쪽이 OOP 관점에서 깔끔하다.

  • UDataAsset = 디자이너가 에디터에서 만지는 데이터 인스턴스
  • TSoftObjectPtr = 지연 로딩 — 사운드 1MB 가 항상 메모리 상주하지 않음
  • 코드 수정 없이 DA_Pistol, DA_Sniper 추가 가능 (확장에 열려있음)

필드 목록

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
UCLASS(BlueprintType)
class UVOIDWeaponConfig : public UDataAsset
{
    EVOIDWeaponClass WeaponClass;
    int32   PelletCount;        // 라이플=1, 샷건=8
    float   SpreadDegrees;      // 산탄 각도
    float   Range;
    float   DamagePerPellet;
    float   FireInterval;
    float   RecoilPitch;
    float   RecoilYawJitter;
    float   RecoilRecoverPerSec;
    float   NoiseRadius;
    TSoftObjectPtr<USoundBase> FireSound;     // 지연 로딩
    TSoftClassPtr<UCameraShakeBase> CameraShakeClass;
};

Items/ 아래 두면 PickupBase 와 섞여서 검색 어려움 → Weapon/ 디렉토리로 이동. 모듈 분리는 비용 대비 이득이 작아 보류.



기술 구현 3 — 반동: 누적과 FInterpTo 회복

모델

1
2
3
4
5
6
7
8
9
Fire 발생
  → PendingRecoilPitch += RecoilPitch
  → PendingRecoilYaw   += FRandRange(-Jitter, +Jitter)

Tick (매 프레임)
  → AddControllerPitchInput(-PendingRecoilPitch)   // 음수가 위쪽
  → AddControllerYawInput(  PendingRecoilYaw)
  → PendingRecoilPitch = FInterpTo(PendingRecoilPitch, 0, dt, RecoilRecoverPerSec)
  → PendingRecoilYaw   = FInterpTo(PendingRecoilYaw,   0, dt, RecoilRecoverPerSec)

Pawn 측 처리

1
2
3
4
5
6
7
8
9
10
11
12
void AVOIDPlayerCharacter::Tick(float dt)
{
    Super::Tick(dt);
    TickAds(dt);
    if (WeaponComp)
    {
        float P=0, Y=0;
        WeaponComp->TickRecoil(dt, P, Y);
        if (!FMath::IsNearlyZero(P)) AddControllerPitchInput(P);
        if (!FMath::IsNearlyZero(Y)) AddControllerYawInput(Y);
    }
}

샷건 (RecoilPitch=4.0) 은 위로 강하게 튀고 0.25초 안에 복귀. 라이플 (1.2) 은 미세하게 튐.



기술 구현 4 — 1/2 키 무기 스왑

헤더 슬롯 추가

1
2
3
4
5
6
7
8
9
10
11
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category="Input")
TObjectPtr<UInputAction> SwitchToRifleAction;

UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category="Input")
TObjectPtr<UInputAction> SwitchToShotgunAction;

UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category="Loadout")
TObjectPtr<UVOIDWeaponConfig> RifleConfig;

UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category="Loadout")
TObjectPtr<UVOIDWeaponConfig> ShotgunConfig;

바인딩 + 핸들러

1
2
3
4
5
6
7
8
9
if (SwitchToRifleAction)
    EIC->BindAction(SwitchToRifleAction, ETriggerEvent::Started, this, &AVOIDPlayerCharacter::OnSwitchToRifle);
if (SwitchToShotgunAction)
    EIC->BindAction(SwitchToShotgunAction, ETriggerEvent::Started, this, &AVOIDPlayerCharacter::OnSwitchToShotgun);

void AVOIDPlayerCharacter::OnSwitchToRifle(const FInputActionValue&)
{
    if (RifleConfig) EquipWeapon(RifleConfig);
}

에디터에서 IA_SwitchToRifle/Shotgun Input Action + IMC 매핑(1/2 키) + BP Defaults 4 슬롯(IA 2 + DataAsset 2) 할당.



같은 날 함께 진행한 비동기 가시선 트레이스(AI), 차량 부품 슬롯 인터랙션, 콜리전 스윕 디버깅, CS 모의면접 정리는 무기 시스템과 다른 주제라 덜어냈다. 동기·비동기 Trace의 선택 기준은 커스텀 DamageType과 동기·비동기 Trace에 정리해 두었다.

핵심 요약 — 무기 로직을 컴포넌트 + DataAsset으로 분리하면 무기 추가가 코드 수정 없이 가능해진다. 그리고 TObjectPtr도 GC 직후 한 프레임은 dangling일 수 있어서 IsValid 가드를 기본으로 깔아야 한다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.