[CH3-07] PDA로 Data Table 참조하기 (Soft Ref)

김여울·2025년 8월 14일

내일배움캠프

목록 보기
60/139

총기, 총알, 발사방식 정보가 나오는 인게임 UI를 만들었는데 총기가 준모님의 데이터 테이블로 연결되어 있어서 연결이 안됐다.

데이터 테이블이 내 담당이 아니어서 건들지 않는 방법으로...
블루프린트로 만들었던 Primary Data Asset을 C++ 클래스로 만들고 DT Row가 PDA를 참조(Soft)를 했다.
그리고 UI로는 PDA만 사용하기로 했다.

🧩 구조

[DT_Weapon(Row)]
   └─ WeaponData(Soft Ref to PDA_XXX)
                ▼
          [UWeaponDataAsset(네 C++)]
                ▼
  픽업/이큅 마스터가 BeginPlay에 PDA 캐시
                ▼
        캐릭터 스폰/UI는 PDA만 참조

1️⃣ PDA를 C++ 클래스로 만들기

WeaponTray.h

#pragma once

#include "CoreMinimal.h"
#include "Blueprint/UserWidget.h"
#include "WeaponTray.generated.h"


// 클래스 전방 선언
class UTextBlock;
class UImage;
class UWidgetAnimation;
class UWidget;
class UTexture2D;
class APppCharacter;
class AEquipWeaponMaster;

UCLASS(BlueprintType, Blueprintable)
class PPP_API UWeaponTray : public UUserWidget
{
    GENERATED_BODY()

public:
    // 위젯 생성 시 호출되는 함수 (델리게이트 바인드)
    virtual void NativeConstruct() override;
    // 위젯 파괴 시 호출되는 함수 (델리게이트 해제)
    virtual void NativeDestruct() override;

    // 위젯이 보이는지 여부를 설정하는 함수
    UFUNCTION(BlueprintCallable, Category="UI")
    void SetHudVisible(bool bVisible);

    // 무기 정보를 업데이트 하는 함수
    // 이름 + 애니만 처리
    UFUNCTION(BlueprintCallable, Category = "UI")
    void UpdateWeaponInfo(const FText& NewWeaponName, UTexture2D* NewWeaponImage);

    UFUNCTION(BlueprintCallable, Category="UI")
    void UpdateAmmoText(int32 NewAmmoInMag, int32 NewReserveAmmo);

protected:
    // 블루프린트 위젯 바인딩
    UPROPERTY(meta = (BindWidget))
    TObjectPtr<UTextBlock> WeaponNameText;
    UPROPERTY(meta = (BindWidget))
    TObjectPtr<UImage> WeaponImage;
    UPROPERTY(meta = (BindWidget))
    TObjectPtr<UTextBlock> CurrentAmmoText;
    UPROPERTY(meta = (BindWidget))
    TObjectPtr<UTextBlock> ReserveAmmoText;
    UPROPERTY(meta = (BindWidget))
    TObjectPtr<UTextBlock> FireModeText;

    // 시작 시 숨김/표시 전환용 루트
    UPROPERTY(meta=(BindWidgetOptional))
    TObjectPtr<UWidget> TrayAnchor;

    // 애니메이션 바인딩
    // UPROPERTY(meta = (BindWidgetAnim), Transient)
    // UWidgetAnimation* WeaponSwap = nullptr;

private:
    // 델리게이트 바인드용
    UFUNCTION()
    void HandleWeaponChanged(AEquipWeaponMaster* NewWeapon);

    UFUNCTION()
    void HandleAmmoChanged(int32 InMag, int32 Reserve);

    // Fire Mode UI 갱신
    void UpdateFireModeTextFromWeapon(AEquipWeaponMaster* Weapon);

    // 캐릭터 참조를 저장
    UPROPERTY() APppCharacter* CachedCharacter = nullptr;

    // 무기 보유 여부 가드
    bool bHasWeapon = false;
};

WeaponTray.cpp

#include "WeaponTray.h"
#include "Components/TextBlock.h"
#include "Components/Image.h"
#include "Components/Widget.h"
#include "Kismet/GameplayStatics.h"
#include "PppCharacter.h"
#include "EquipWeaponMaster.h"
#include "Engine/Texture2D.h"
#include "PPP/GameMOde/GameDefines.h"

void UWeaponTray::SetHudVisible(bool bVisible)
{
    if (TrayAnchor)
    {
        TrayAnchor->SetVisibility
        (
            bVisible ? ESlateVisibility::SelfHitTestInvisible
                     : ESlateVisibility::Collapsed
        );
    }
}

void UWeaponTray::NativeConstruct()
{
    Super::NativeConstruct();

    // 처음엔 무기 UI 숨김
    SetHudVisible(false);
    bHasWeapon = false;

    // 플레이어 캐릭터 캐싱
    CachedCharacter = Cast<APppCharacter>(UGameplayStatics::GetPlayerCharacter(this, 0));
    if (CachedCharacter)
    {
        CachedCharacter->OnWeaponChanged.AddDynamic(this, &UWeaponTray::HandleWeaponChanged);
        CachedCharacter->OnAmmoChanged.AddDynamic(this, &UWeaponTray::HandleAmmoChanged);
    }
}

void UWeaponTray::NativeDestruct()
{
    CachedCharacter = Cast<APppCharacter>(UGameplayStatics::GetPlayerCharacter(this, 0));
    if (CachedCharacter)
    {
        CachedCharacter->OnWeaponChanged.AddDynamic(this, &UWeaponTray::HandleWeaponChanged);
        CachedCharacter->OnAmmoChanged.AddDynamic(this, &UWeaponTray::HandleAmmoChanged);
    }
    Super::NativeDestruct();
}

void UWeaponTray::UpdateWeaponInfo(const FText& NewWeaponName, UTexture2D* NewWeaponImage)
{
    // 이름만 갱신
    if (WeaponNameText)
    {
        WeaponNameText->SetText(NewWeaponName);
    }

    if (WeaponImage)
    {
        if (NewWeaponImage)
        {
            WeaponImage->SetBrushFromTexture(NewWeaponImage /*, true*/);
        }
        else
        {
            WeaponImage->SetBrushFromTexture(nullptr);
        }
    }
}

void UWeaponTray::UpdateAmmoText(int32 NewAmmoInMag, int32 NewReserveAmmo)
{
    if (CurrentAmmoText != nullptr)
    {
        CurrentAmmoText->SetText(FText::AsNumber(NewAmmoInMag));
    }

    if (ReserveAmmoText != nullptr)
    {
        ReserveAmmoText->SetText(FText::AsNumber(NewReserveAmmo));
    }
}


void UWeaponTray::HandleWeaponChanged(AEquipWeaponMaster* NewWeapon)
{
    if (!NewWeapon)
    {
        // 무기 없음 → 숨김 + 초기화
        bHasWeapon = false;
        SetHudVisible(false);

        UpdateWeaponInfo(FText::GetEmpty(), nullptr);

        if (CurrentAmmoText)  CurrentAmmoText->SetText(FText::FromString(TEXT("0")));
        if (ReserveAmmoText)  ReserveAmmoText->SetText(FText::FromString(TEXT("0")));
        if (FireModeText)     FireModeText->SetText(FText());

        if (WeaponImage)      WeaponImage->SetBrushFromTexture(nullptr);
        return;
    }

    // 무기 있음 → 표시
    bHasWeapon = true;
    SetHudVisible(true);

    const FText WeaponName =
        !NewWeapon->WeaponDisplayName.IsEmpty()
            ? NewWeapon->WeaponDisplayName
            : FText::FromName(NewWeapon->GetFName());

    UpdateWeaponInfo(WeaponName, NewWeapon->GetWeaponIcon());
    UpdateFireModeTextFromWeapon(NewWeapon);
    HandleAmmoChanged(NewWeapon->CurrentAmmoInMag, NewWeapon->ReserveAmmo);
    }

void UWeaponTray::HandleAmmoChanged
(
    int32 InMag,
    int32 Reserve
)
{
    if (!bHasWeapon)
    {
        return; // 무기 없으면 무시
    }
    UpdateAmmoText(InMag, Reserve);
}


void UWeaponTray::UpdateFireModeTextFromWeapon
(
    AEquipWeaponMaster* Weapon
)
{
    if (!Weapon || !FireModeText)
    {
        return;
    }
    FireModeText->SetText(Weapon->GetFireModeText());
}

  1. 기존 DA 자산들을 이 클래스로 연결하기
  • 각 DA(예: DA_Assault_Rifle1) 열기
    → Class Settings
    → Parent Class를 UWeaponDataAsset로 바꾸기
    (안 되면 새 DA를 UWeaponDataAsset 타입으로 4개 만들고 값만 복붙) ✔️
  1. 각 DA에 값 채우기
  • WeaponName / MaxMagSize / FireMode / Thumbnails
  • EquipWeaponClass = 무기담당 장착용 BP
  • PickupWeaponClass = 무기담당 픽업용 BP
  1. 캐릭터 장착 코드는 항상 PDA의 클래스로 스폰
  • SpawnActor( Data->EquipWeaponClass ) → 소켓 부착
  • UI는 EquippedWeapon->GetWeaponData()(또는 바로 Data)에서 이름/모드/아이콘 읽기

📌 주의

  • DA 4개가 모두 같은 C++ 클래스(UWeaponDataAsset) 기반인지 확인
  • 무기담당 BP의 부모가 AEquipWeaponMaster / APickUpWeaponMaster 맞는지
  • 스폰 지점이 옛 커스텀 클래스가 아니라 Data->EquipWeaponClass를 쓰는지

2️⃣ DT Row 구조체에 PDA 필드 추가

WeaponRow.h

#pragma once

#include "CoreMinimal.h"
#include "Engine/DataTable.h"   // 데이터 테이블 사용
#include "WeaponTypes.h"    // EWeaponType Enum 사용
#include "WeaponRow.generated.h"

class UWeaponDataAsset; // 무기 데이터 에셋 사용

UENUM(BlueprintType)
enum class EWeaponType : uint8
{
    // enum 설정
    Pistol UMETA(DisplayName = "Pistol"),
    Assault_Rifle UMETA(DisplayName = "Assault_Rifle"),
    Shotgun UMETA(DisplayName = "Shotgun"),
    Rocket_Launcher UMETA(DisplayName = "Rocket_Launcher"),
    UnArmed UMETA(DisplayName = "UnArmed")
};


USTRUCT(BlueprintType)
struct FWeaponRow : public FTableRowBase
{
    GENERATED_BODY()

	// ... 
    
    // PDA SoftRef: 런타임은 PDA만 진실원으로 사용
    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Data")
    TSoftObjectPtr<UWeaponDataAsset> WeaponData;

    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    TSubclassOf<class APickUpWeaponMaster> PickUpWeapon;  // 줍는 무기 종류, Static Mesh 타입

    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    TSubclassOf<class AEquipWeaponMaster> EquipWeapon;  // 장착 중인 무기, Skeletal Mesh 타입

    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    FVector WeaponOffset = FVector::ZeroVector;  // 무기 잡는 손 위치 값 저장 타입

    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    FRotator WeaponRotation = FRotator::ZeroRotator;  // 무기 잡는 손 위치 값 저장 타입

    // 예비 탄약 수, 예) 10 / 1000 이라 가정할 때 1000에 해당됨.
    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    int32 ReserveAmmo = 0;;	// 나중에 데이터 테이블에서 5000으로 변경
};

3️⃣ 픽업/이큅 마스터에서 DT→PDA 캐시

EquipWeaponMaster.h

#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "Sound/SoundBase.h"
#include "WeaponRow.h"
#include "WeaponTypes.h"    // EFireMode Enum 사용
#include "EquipWeaponMaster.generated.h"


// 전방 선언
class UTexture2D;
class UWeaponDataAsset;

// ...

// 탄약 변경 델리게이트
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnWeaponAmmoChanged, int32, AmmoInMag, int32, Reserve);


// 플레이어가 장착하는 무기(장착/발사/드랍 동작과 스탯 적용을 담당)
UCLASS()
class PPP_API AEquipWeaponMaster : public AActor
{
    GENERATED_BODY()

public:
    AEquipWeaponMaster();

    // ...
    // 탄약 변경 이벤트: 블루프린트에서 바인딩 가능
    UPROPERTY(BlueprintAssignable, Category = "Weapon")
    FOnWeaponAmmoChanged OnAmmoChanged;

    // 재장전 함수
    UFUNCTION(BlueprintCallable)
    void Reload();

    // 캐시된 PDA(런타임 진실원)
    UFUNCTION(BlueprintPure, Category="Data")
    UWeaponDataAsset* GetWeaponData() const;

    UFUNCTION(BlueprintPure, Category="Weapon")
    EFireMode GetFireMode() const;

    // UI 편의 (있으면 유지)
    UFUNCTION(BlueprintPure, Category="UI", meta=(DisplayName="GetIcon")) // BP에서는 GetIcon으로 보임
    UTexture2D* GetWeaponIcon() const;

    UFUNCTION(BlueprintPure, Category="UI")
    UTexture2D* GetAmmoIcon() const;
    
    // ...
    // 탄약 변수
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Weapon")
    int32 CurrentAmmoInMag;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Weapon")
    int32 ReserveAmmo;

    // UI 표시용
    UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "UI")
    UTexture2D* WeaponImage = nullptr;

    UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "UI")
    UTexture2D* AmmoImage = nullptr;

    UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "UI")
    FText WeaponDisplayName;

    UFUNCTION(BlueprintPure, Category="Weapon")
    FText GetFireModeText() const;

  
private:
    // 여기로 PDA 캐시
    UPROPERTY() UWeaponDataAsset* CachedData = nullptr;
};

EquipWeaponMaster.cpp

#include "WeaponRow.h"  // FWeaponRow 구조체 사용
#include "WeaponTypes.h"    // EFireMode Enum 사용
#include "../InGame/WeaponDataAsset.h"  // 무기 데이터 에셋 사용

// ...

// 재장전 함수
void AEquipWeaponMaster::Reload()
{
    // PDA > DT > 멤버 순으로 최대 탄창 크기 결정
    const int32 MaxAmmoInMag =
        (CachedData && CachedData->MaxMagSize > 0) ? CachedData->MaxMagSize :
        (WeaponDataRow.MagazineSize > 0)           ? WeaponDataRow.MagazineSize :
                                                     MagazineSize;

    const int32 AmmoNeeded = MaxAmmoInMag - CurrentAmmoInMag;

    // 예비탄이 없거나 이미 가득 차면 재장전 불필요 → 즉시 종료
    if (ReserveAmmo <= 0 || AmmoNeeded <= 0)
    {
        UE_LOG(LogTemp, Warning, TEXT("재장전할 탄약이 없거나, 탄창이 이미 가득 찼습니다."));
        OnAmmoChanged.Broadcast(CurrentAmmoInMag, ReserveAmmo);
        return;
    }

    const int32 Use = FMath::Min(ReserveAmmo, AmmoNeeded);
    CurrentAmmoInMag += Use;
    ReserveAmmo      -= Use;

    // 탄약 변경 델리게이트 호출
    OnAmmoChanged.Broadcast(CurrentAmmoInMag, ReserveAmmo);
}

UWeaponDataAsset* AEquipWeaponMaster::GetWeaponData() const
{
    return CachedData;
}

EFireMode AEquipWeaponMaster::GetFireMode() const
{
    return CachedData ? CachedData->FireMode : EFireMode::Single;
}

UTexture2D* AEquipWeaponMaster::GetWeaponIcon() const
{
    return CachedData ? CachedData->WeaponThumbnail : WeaponImage;
}

UTexture2D* AEquipWeaponMaster::GetAmmoIcon() const
{
    return CachedData ? CachedData->AmmoThumbnail : AmmoImage; // PDA 우선
}

FText AEquipWeaponMaster::GetFireModeText() const
{
    switch (GetFireMode())
    {
    case EFireMode::Single: return NSLOCTEXT("Weapon", "Single", "Single");
    case EFireMode::Auto:   return NSLOCTEXT("Weapon", "Auto",   "Auto");
    default:                return NSLOCTEXT("Weapon", "Unknown","Unknown");
    }
}

PickUpWeaponMaster.cpp

 	// 신규 장비가 존재하면 스폰 후 장착 및 OnEquipped 호출 (무기 장착 기능)
    if (WeaponRow.EquipWeapon)
    {
        UE_LOG(LogTemp, Warning, TEXT("스폰 시도 - Skeletal Weapon: %s"), *WeaponRow.EquipWeapon->GetName());
        FActorSpawnParameters SpawnParams;
        SpawnParams.Owner = Character;

        AEquipWeaponMaster* NewWeapon = Character->GetWorld()->SpawnActor<AEquipWeaponMaster>(
            WeaponRow.EquipWeapon,
            FVector::ZeroVector,
            FRotator::ZeroRotator,
            SpawnParams
        );
        if (NewWeapon)
        {
            UE_LOG(LogTemp, Warning, TEXT("NewWeapon 스폰 성공! OnEquipped 호출"));
            NewWeapon->OnEquipped(Character, WeaponRow);
            // Character->EquippedWeapon = NewWeapon;   // 준모님 아랫줄의 세터로 바꿔도 돼요?
            // by Yeoul
            UE_LOG(LogTemp, Warning, TEXT("[PickUp] After OnEquipped: Icon=%s, Ammo=%s"),
                *GetNameSafe(NewWeapon->GetWeaponIcon()),
                *GetNameSafe(NewWeapon->GetAmmoIcon()));
            // 세터로 교체 (여기서 OnWeaponChanced 브로드캐스트됨)
            Character->SetEquippedWeapon(NewWeapon);
        }
        else
        {
            UE_LOG(LogTemp, Error, TEXT("NewWeapon SK 스폰 실패"));
        }
    }

4️⃣ 캐릭터는 PDA의 클래스로 스폰

void APppCharacter::BeginPlay()
{
    Super::BeginPlay();
    ToggleCamera();

    // ...
   
    // 시작 시 UI에 '무기 없음' 신호
    OnWeaponChanged.Broadcast(nullptr);
}

// 무기 변경 델리게이트
void APppCharacter::SetEquippedWeapon(AEquipWeaponMaster* NewWeapon)
{
    // 현재 무기와 새로 지정하려는 무기가 같으면 아무것도 안 함
    if (EquippedWeapon == NewWeapon)
    {
        return; // 중복 변경 방지
    }

    // 기존 무기 델리게이트 언바인드
    if (EquippedWeapon != nullptr)
    {
        EquippedWeapon->OnAmmoChanged.RemoveDynamic(this, &APppCharacter::OnWeaponAmmoChanged);

        // 정현성
        // 기존 무기 히트, 킬 델리게이트 언바인드
        EquippedWeapon->OnWeaponHit.RemoveDynamic(this, &APppCharacter::ShowHitMarker);
        EquippedWeapon->OnWeaponKilled.RemoveDynamic(this, &APppCharacter::ShowKillMarker);
    }

    // 교체
    EquippedWeapon = NewWeapon;

    // 무기 변경 알림
    OnWeaponChanged.Broadcast(NewWeapon);

    // 새 무기 델리게이트 바인드
    if (EquippedWeapon != nullptr)
    {
        EquippedWeapon->OnAmmoChanged.AddDynamic(this, &APppCharacter::OnWeaponAmmoChanged);

        // 정현성
        // 새 무기 히트, 킬 델리게이트 바인드
        EquippedWeapon->OnWeaponHit.AddDynamic(this, &APppCharacter::ShowHitMarker);
        EquippedWeapon->OnWeaponKilled.AddDynamic(this, &APppCharacter::ShowKillMarker);

        // 즉시 현재 탄약 상태를 UI에 반영 (안전빵)
        OnWeaponAmmoChanged(EquippedWeapon->CurrentAmmoInMag,
                            EquippedWeapon->ReserveAmmo);
    }
    else
    {
        // 맨손 등
        OnWeaponAmmoChanged(0, 0);
    }
}

/**무기 관련 */
void APppCharacter::OnInteract()
{
    UE_LOG(LogTemp, Warning, TEXT("OnInteract() 호출됨"));

    if (OverlappingPickUpActor && OverlappingPickUpActor->PickUpComp)
    {
        UE_LOG(LogTemp, Warning, TEXT("OverlappingPickUpActor 존재, TryPickUp 실행"));
        OverlappingPickUpActor->PickUpComp->TryPickUp(this);
    }
    else
    {
        UE_LOG(LogTemp, Warning, TEXT("F key pressed but no weapon overlapped"));
    }
}

// 탄약 변경 이벤트 콜백 구현
void APppCharacter::OnWeaponAmmoChanged(int32 CurrentAmmoInMag, int32 ReserveAmmo)
{
    // 탄약 변경 이벤트 발생
    OnAmmoChanged.Broadcast(CurrentAmmoInMag, ReserveAmmo);
    UE_LOG(LogTemp, Warning, TEXT("탄약 변경: 현재 탄창 %d, 예비 탄약 %d"), CurrentAmmoInMag, ReserveAmmo);
}

// 성준모, 장전 입력 시 호출되는 함수 구현
void APppCharacter::OnReload()
{
    UE_LOG(LogTemp, Warning, TEXT("재장전 R키 입력하셨습니다."));

    //UAnimMontage* MontageToPlay;
    // 장착된 무기가 없을 때 실행
    if (!EquippedWeapon)
    {
        UE_LOG(LogTemp, Warning, TEXT("Reload 실패 : 장착된 무기가 없습니다."));
        return;
    }

    // 재장전 중복 방지
    if (bIsReloading)
    {
        // Verbose : 상세 로그 레벨로, 매우 세부적인 진단용 메세지.
        UE_LOG(LogTemp, Verbose, TEXT("이미 재장전 중입니다."));
        return;
    }

    int32 MaxAmmoInMag = EquippedWeapon->WeaponDataRow.MagazineSize;

    // PDA 값이 유효하면 MagazineSize 덮어씀
    if (EquippedWeapon->GetWeaponData() && EquippedWeapon->GetWeaponData()->MaxMagSize > 0)
    {
        MaxAmmoInMag = EquippedWeapon->GetWeaponData()->MaxMagSize;
    }

    // 여기서 재장전 필요 여부 체크
    if (EquippedWeapon->CurrentAmmoInMag >= MaxAmmoInMag ||
        EquippedWeapon->ReserveAmmo <= 0)
    {
        UE_LOG(LogTemp, Log, TEXT("재장전 불필요: 탄창 가득 또는 예비탄 없음"));
        return;
    }

    // 데이터 테이블에서 온 ReloadTime 사용
    float ReloadTime = EquippedWeapon->WeaponDataRow.ReloadTime;

    bIsReloading = true;  // 핵심 함수, 무기 재장전 (무기 Reload 호출)

    // 유효한 장전 시간이면 타이머, 아니면 즉시 완료
    if (ReloadTime > 0.f)
    {
        EquippedWeapon->PlayReloadAnimation(); // 무기 내부에서 SkeletalMesh로 재생

        GetWorldTimerManager().SetTimer(ReloadTimerHandle, this, &APppCharacter::FinishReload, ReloadTime, false);
    }
    else
    {
        // 장전 시간이 0이거나 잘못된 경우 즉시 완료 처리
        FinishReload();
    }
}

5️⃣ 에디터에서 연결 순서

  1. 각 무기 PDA 생성: DA_AssaultRifle1, DA_Shotgun1

    • EquipWeaponClass = 무기담당 장착 BP
    • PickupWeaponClass = 무기담당 픽업 BP
    • 나머지 썸네일/파이어모드/탄약값 채우기

    → 기존 데이터 에셋은 연결이 안되므로 새로 만들어서 데이터 테이블의 WeaponData에 추가하기

  2. DT_Weapon 열기 → 각 Row의 WeaponData 칼럼에 해당 PDA 지정

  3. 픽업/이큅 액터(C++ 클래스 또는 BP 자식)의 WeaponRowDT_Weapon + RowName 선택

    • 픽업과 이큅 둘 다 같은 Row여야 함

0개의 댓글