포스트

TIL 2026-04-28

TIL 2026-04-28

2026-04-28 8번 과제 Day 4 — 무기 시스템 캡슐화 + 비동기 가시선 + 차량 슬롯 E 키 통합

목차


오늘 한 일 요약

  1. 무기 시스템 캡슐화UVOIDWeaponComponent 도입, Pawn 은 입력 → 컴포넌트 위임만 담당 (단일 책임)
  2. 샷건/라이플 DataAsset 분리UVOIDWeaponConfig (UDataAsset) 로 펠릿/스프레드/레인지/반동/소음/FX 전 파라미터 외부화 → DA_Rifle / DA_Shotgun 인스턴스
  3. 반동 시스템 — 발사 시 PendingRecoilPitch/Yaw 누적 + TickRecoil 에서 FInterpTo 로 0 보간, Pawn Tick 에서 컨트롤러 입력 적용
  4. 1/2 키 무기 스왑IA_SwitchToRifle/Shotgun Enhanced Input + RifleConfig/ShotgunConfig BP Defaults 슬롯
  5. CurrentWeapon dangling 가드IsValid 체크 + 쓰레기값 (Interval=532053344) 디버그 케이스 폴백
  6. VOIDWeaponConfig 디렉토리 이동Items/Weapon/ (모듈 분리는 불필요, 디렉토리만)
  7. ADS 적용 확인 — FOV/SpringArm 보간 + 이동속도 곱셈 합성 (무게·ADS) 정상 동작
  8. 비동기 가시선 (도전 과제)AVOIDZombieAIControllerAsyncLineTraceByChannel + FTraceDelegate 콜백, bUseAsyncSight 토글로 실행, 콜백에서 BB TargetActor 갱신
  9. 비동기 트레이스 디버그 시각화 — 가시선 확보 = 녹색 라인 + 청록 구체, 차단 = 빨강 라인 + 노랑 구체 (bDrawAsyncSightDebug 토글)
  10. 차량 부품 슬롯 인터랙션 (E 키 통합) — F 키 분리 폐기, Interact() 한 곳에서 슬롯 → 차량 시동 → 픽업 우선순위 분기
  11. PartType 매칭 추가UVOIDItemDataAsset::PartType 필드 신설, InventoryComponent::FindPartByType 로 슬롯 타입에 맞는 부품 자동 선택
  12. Sweep 콜리전 진단 — 헬기 메시(StaticMeshActor)가 ECC_Visibility Block 응답이라 SweepMulti 가 헬기에서 끊김 → 헬기 데코 콜리전 OFF + 코드에서 AStaticMeshActor 후보 컷
  13. CS 모의면접 — “객체 복사 방지”12_prevent_copy.md 파일 핸들·뮤텍스·소켓 RAII 키워드 보강, 슬라이싱·Rule of Five/Zero·friend 정리
  14. 마스터.md 작성D:\Unreal\VoidUnreal\마스터.md 필수/도전 과제 핵심 코드 라인 매핑 + 검증 체크리스트


무기 시스템 캡슐화 — 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/단발)로 동작 → 크래시 없음.



무기 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/ 디렉토리로 이동. 모듈 분리는 비용 대비 이득이 작아 보류.



반동 시스템 — 누적 + 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) 은 미세하게 튐.



1/2 키 무기 스왑 (Enhanced Input)

헤더 슬롯 추가

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) 할당.



비동기 가시선 트레이스 (도전 과제)

청각(AIPerception)과 시각(AsyncTrace) 역할 분담

시스템트리거용도
AIPerception Hearing발사음·픽업음노이즈 방향 감지
AsyncLineTrace0.2초 주기 Tick시야 안 진입 감지 (벽 너머는 컷)

둘 다 같은 BB TargetActor / bHasTarget 를 공유 → BT 가 일관된 추격 로직.

비동기 호출 + 콜백

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void AVOIDZombieAIController::RequestAsyncSight()
{
    GetWorld()->AsyncLineTraceByChannel(
        EAsyncTraceType::Single,
        Start, End,
        ECC_Visibility,
        Params,
        FCollisionResponseParams::DefaultResponseParam,
        &AsyncTraceDelegate);  // 콜백
}

void AVOIDZombieAIController::HandleAsyncTrace(const FTraceHandle&, FTraceDatum& Data)
{
    const bool bBlocked = (Data.OutHits.Num() > 0 && Data.OutHits[0].bBlockingHit);
    if (UBlackboardComponent* BB = GetBlackboardComponent())
    {
        if (!bBlocked)
        {
            BB->SetValueAsObject(TEXT("TargetActor"), UGameplayStatics::GetPlayerPawn(this, 0));
            BB->SetValueAsBool(TEXT("bHasTarget"), true);
        }
    }
}

핵심: 게임 스레드 블로킹 없음. 동기 LineTraceSingleByChannel 을 좀비 N마리에 매 틱 돌리면 stat unit 에서 게임 스레드 정체. 비동기로 분산 → 콜백은 다음 틱에 자동.

디버그 시각화

bDrawAsyncSightDebug 토글 → 콜백에서 라인/구체 그림. 가시선 확보 = 녹색 / 차단 = 빨강. 라인 수명을 AsyncTraceInterval * 1.1f 로 줘서 끊김 없이 갱신.



차량 부품 슬롯 — E 키 통합

F 키 폐기 → E 키 단일화

처음엔 IA_StartEngine (F키) 별도로 두려 했지만 인터랙션 키가 둘이면 UX 산만 → Interact() 한 곳에서 우선순위 분기:

1
2
3
4
5
6
7
8
9
10
11
12
if (Best->Implements<UVOIDVehiclePartSlot>())
{
    // 1) 부품 설치
    return;
}
if (auto* Vehicle = Cast<AVOIDVehicle>(Best))
{
    // 2) 차량 시동
    Vehicle->TryStartEngine(this);
    return;
}
// 3) 일반 픽업

PartType 정확 매칭

기존: Interact()CandidatePart=nullptr 로 호출 → TryInstallPart 첫 가드(!IsValid(Part))에서 즉시 false.

수정: UVOIDItemDataAssetEVOIDVehiclePartType PartType 필드 추가 + UVOIDInventoryComponent::FindPartByType(SlotType) 로 인벤토리에서 슬롯 타입에 맞는 부품 검색.

1
2
3
4
const EVOIDVehiclePartType SlotType = IVOIDVehiclePartSlot::Execute_GetRequiredPartType(Best);
UVOIDItemDataAsset* CandidatePart = InventoryComponent->FindPartByType(SlotType);
if (!CandidatePart) return;
IVOIDVehiclePartSlot::Execute_TryInstallPart(Best, CandidatePart, this);


콜리전 충돌 디버깅 — SweepMulti 가 헬기 메시에서 끊김

증상

E 키 눌러도 슬롯이 Hit 목록에 안 들어옴. 로그:

1
2
3
[Interact] Sweep hits=2
[Interact]   Hit: lowpoly_heli_node_62_StaticMeshActor (...)
[Interact]   Hit: lowpoly_heli_node_18_StaticMeshActor (...)

원인

  • SweepMultiByChannel(ECC_Visibility)Block 응답 첫 히트에서 멈춤
  • 헬기 메시 액터들이 BlockAll 프리셋 → Sweep 이 헬기에서 끊겨서 슬롯이 후보 목록에 영영 안 들어옴
  • 처음엔 차량 BP 의 ChildActor 일 거라 가정하고 TActorIterator<AVOIDVehicle> 로 무시했지만 — 헬기 메시는 레벨에 직접 배치된 별도 StaticMeshActor 였음

해결

  1. 레벨 헬기 메시 콜리전 OFF (Outliner 다중 선택 → Collision Preset = NoCollision)
  2. 코드 방어AStaticMeshActor 후보는 Hit 루프에서 컷:
1
2
const bool bStaticDecor = HitActor->IsA(AStaticMeshActor::StaticClass());
if (bStaticDecor) continue;

PIE 검증은 내일로 이월.



CS — 객체 복사 방지 모의면접

오늘 모의면접 주제. 12_prevent_copy.md 키워드 추가:

막는 방법

  1. = delete (C++11)
  2. private 복사 생성자 (C++03)
  3. noncopyable 패턴 (boost)
  4. unique_ptr 멤버 (자동 전이)

왜 막는가 — RAII 자원의 이중 해제

  • 파일 핸들 (FILE*, std::ifstream) — 같은 fd 두 번 close
  • 뮤텍스 (std::mutex) — 동일 락 두 번 unlock → UB
  • 소켓 (SOCKET) — fd 재사용 후 잘못된 fd 닫힘
  • unique_ptr — 동일 힙 객체 두 번 delete

슬라이싱 / Rule of Three~Five~Zero / friend

  • 슬라이싱: Base = Derived 값 복사 → Derived 부분 잘림 + vtable 손상 → 다형성 깨짐
  • Three (C++03): 복사 생성자 / 복사 대입 / 소멸자
  • Five (C++11+): + 이동 생성자 / 이동 대입
  • Zero: RAII 멤버에 위임해서 명시 정의 0개 (모던 권장)
  • friend: 다른 클래스/함수에 private 접근 허용. 캡슐화의 의도적 예외 (연산자 오버로딩, 팩토리, 테스트)


마스터.md 작성

언리얼 마스터 과제(샷건+반동+커스터마이징 / 비동기 Trace) 별도 레포 제출용 가이드. 각 항목마다 파일경로:라인번호 매핑 + PIE 검증 체크리스트. 채점자가 코드 위치를 즉시 점프 가능.



Day 4 진행 정리

항목상태비고
무기 시스템 캡슐화UVOIDWeaponComponent + Pawn 위임
샷건/라이플 DataAsset 분리DA_Rifle, DA_Shotgun
반동 누적 + 회복FInterpTo + Tick 처리
1/2 키 무기 스왑Enhanced Input + IMC 매핑
ADS FOV/SpringArm 보간무게·ADS 이동속도 곱셈 합성
비동기 가시선 (도전)AsyncLineTraceByChannel + 콜백
비동기 디버그 시각화녹/빨/청록/노랑 토글
E 키 통합 인터랙션슬롯 → 시동 → 픽업 우선순위
PartType 매칭FindPartByType 추가
헬기 콜리전 OFF부분 완료코드 수정·콜리전 변경 끝, PIE 검증 내일
마스터.md 작성VoidUnreal 별도 레포 제출용
Day 4 커밋진행 중

Day 4 → Day 5 인계:

  • 헬기 콜리전 OFF 후 PIE 검증 (E 키로 슬롯 3개 부품 설치 → 무게 감소 → bRepairComplete=true)
  • 차량 시동 (TryStartEngine) 분기 정상 동작 확인
  • GUI (HUD) 활성화 — 인벤토리 무게 게이지, 현재 무기, 웨이브/타이머, 차량 수리 진행도
  • 레벨 전환 (8번 발제 추가 요구사항) — Lv_Main / Lv_VoidProto / Lv_Escape


오늘 배운 것 정리

  1. 컴포넌트 분리는 진작 했어야 함 — Pawn 에 무기 로직 다 박아놓다가 1/2 키 스왑 추가하려니 멤버 정리부터 시작했다. 처음부터 UVOIDWeaponComponent 로 갔으면 깔끔. “이 책임은 누구의 것인가” 를 매 멤버마다 묻는 습관이 단일 책임 원칙으로 가는 길.

  2. TObjectPtr 도 dangling 가능 — GC 시점 직후 한 프레임 동안 raw 포인터처럼 쓰레기값을 가질 수 있다. IsValid 가드를 디폴트로 두고, 디버거에서 비정상값 (Interval=532053344) 보이면 dangling 의심. 폴백 경로(기본 데미지/단발) 로 크래시 없이 진행.

  3. 콜리전 디버깅은 로그 + 시각화가 답SweepMulti 가 어디서 끊기는지 Hit 목록 출력으로 즉시 진단했다. lowpoly_heli_node_* 가 후보로 잡히는 순간 원인 확정. 추측보다 증거를 먼저.

  4. UDataAsset = 데이터 인스턴스, 디자이너 친화 + 코드 폐쇄 — 무기 종류 추가가 코드 수정 0줄로 가능 (DA_Pistol 추가 + BP Defaults 슬롯 매핑만). OCP(개방-폐쇄) 가 데이터 측에서 구현되면 시스템 확장이 가벼워진다.

  5. TSoftObjectPtr 지연 로딩 — 사운드/머티리얼 같은 큰 자산은 강한 참조로 들고 있으면 모든 무기 DA 가 메모리 상주. Soft 로 두면 실제 사용 시점에만 LoadSync. 메모리 풋프린트가 즉시 줄어든다.

  6. 반동은 “변위 누적 + 보간 회복” 두 변수로 충분 — 복잡한 reset 타이머 없이 매 Tick FInterpTo 한 줄이면 자연스러운 반동 회복이 된다. 게임 시스템에서 시간 기반 상태 머신을 만들기 전에 “보간 한 줄로 표현 가능한가” 를 먼저 물어보자.

  7. 비동기 트레이스의 게임 스레드 양보 — N마리 좀비가 매 틱 동기 LineTrace 하면 게임 스레드 정체. AsyncLineTraceByChannel + 콜백으로 분산하면 stat unit 의 GameThread 부담이 즉시 떨어진다. 워커 스레드 1개만 있어도 N개 트레이스 분산 가능.

  8. 인터페이스 우선순위 분기로 키 통합 — F 키 별도로 두지 말고 E 키 안에서 Implements<UVOIDVehiclePartSlot>IsA<AVOIDVehicle>Implements<UVOIDItemInterface> 순으로 분기. 입력 키 1개로 모든 인터랙션 통합. UX 일관성 + 코드 진입점 1곳.

  9. 객체 복사 방지의 본질은 RAII 자원의 이중 해제 — 파일 핸들·뮤텍스·소켓·unique_ptr 가 복사되면 같은 자원을 두 객체가 들고 있다가 둘 다 해제하면 더블 free. = delete / 이동 시맨틱 / unique_ptr 멤버 패턴이 다 같은 문제를 다른 각도에서 푼다.

  10. Rule of Five 보다 Rule of Zero 가 모던 — 직접 정의 0개로 두고 RAII 멤버(std::vector, unique_ptr) 가 알아서 처리. 직접 정의가 늘면 늘수록 버그 표면적도 늘어난다. 정의는 정말 필요한 한 가지에만 집중.

  11. 마스터 가이드 = file:line 매핑 — 채점자/리뷰어가 코드 위치를 즉시 점프할 수 있게 Source/.../File.cpp:123 형식으로 핵심 라인 표기. PR 설명·과제 가이드는 “어디 어디 어떻게” 가 아니라 “여기 클릭해서 보세요” 가 더 빠르다.

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