1. 5초간 안보이게 하기(투명화) -> positive
2. 5초간 술래 못 움직이게 하기 (투사체 - 던져서 맞춤) - positive
3. 이상한 이모션 몇초간 유지 - negative
4. 3초간 소리나게 하기 - negative
EItemEffect
- 아이템 효과 이름표
(일단 지금은 4개 - Invisibility / TaggerStun / ForcedEmote / Noise)
FItemData
- 효과의 설정값 묶음 (얼마나 지속되는지, 보관형인지 즉시발동인지, 뽑힐 확률이 얼마인지)
C++ 코드에 struct 정의
헤더만 필요하니까 Rider에서 Item 폴더 만들고 우클릭 -> Add -> File -> FDItemTypes.h 생성
// FDItemTypes.h
#pragma once
#include "CoreMinimal.h"
#include "Engine/DataTable.h"
#include "FDItemTypes.generated.h"
// 아이템 종류
UENUM(BlueprintType)
enum class EFDItemEffect : uint8
{
None,
Invisibility, // 하이더 투명화
TaggerStun, // 술래 멈추기 - 던져서 맞춤
ForcedEmote, // 강제 이모션
Noise // 소리 발생
};
USTRUCT(BlueprintType)
struct FFDItemData : public FTableRowBase
{
GENERATED_BODY()
public:
// 실제로 발동시킬 효과 종류
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Item")
EFDItemEffect Effect;
// true: 주우면 인벤토리에 보관 (1,2번) / false: 줍는 즉시 발동 (3,4번)
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Item")
bool bStoredOnPickup;
// 효과 지속 시간
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Item")
float Duration;
// 뽑기 가중치
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Item")
float DrawWeight;
// 투척형(TaggerStun)일 때 스폰할 투척 아이템 클래스
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Item")
TSubclassOf<class AFDThrowItem> ThrowItemClass;
// 기본값 설정
FFDItemData()
: Effect(EFDItemEffect::None)
, bStoredOnPickup(true)
, Duration(5.0f)
, DrawWeight(10.0f)
, ProjectileClass(nullptr)
{
}
};
그리고 투척 아이템 클래스 생성 (부모 Actor로 - 콜리전 붙이고 메시 붙여야 하니까)
- 일단 구현은 안 하고 생성만
이 struct 기반 DataTable 에셋 생성
- Miscellaneous -> Data Table
- Pick Row Structure 창 목록에서 FFDItemData 검색해서 선택
- DT_ItemData

보관형만 sotred on pickup true 설정 / 아니면 false
일단 지속시간이랑 확률은 전부 맞춰놓음 -> 이후 수정 예정
throw item class는 taggerstun만 이후에 throwitem 구현해서 bp 만들면 연결할 예정
인벤토리 컴포넌트
하이더가 지금 뭘 들고 있는지 기억하고
박스로부터 뽑기 결과를 받아서 보관할지 즉시 발동할지 판단하고
플레이어가 사용 입력을 주면 보관해둔 효과를 실제로 실행하는 부품
// FDItemInventoryComponent.h
#pragma once
#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "Item/FDItemTypes.h"
#include "FDItemInventoryComponent.generated.h"
UCLASS(ClassGroup = (Item), meta = (BlueprintSpawnableComponent))
class FUNNYORDIE_API UFDItemInventoryComponent : public UActorComponent
{
GENERATED_BODY()
public:
UFDItemInventoryComponent();
// 뽑은 결과를 넘겨줄 때 호출
// bStoredOnPickup에 따라 보관하거나 즉시 발동
void ReceiveDrawnItem(FName ItemRow);
// 보관 중인 아이템 사용 요청 (클라이언트 → 서버, 아이템 사용 입력에서 호출)
UFUNCTION(Server, Reliable)
void Server_UseHeldItem();
protected:
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
// 현재 보관 중인 아이템의 Row Name
// 소유 클라이언트가 UI에 표시해야 하므로 복제함
UPROPERTY(ReplicatedUsing = OnRep_HeldItemRow)
FName HeldItemRow = NAME_None;
UFUNCTION()
void OnRep_HeldItemRow();
// 아이템 데이터 테이블 (에디터에서 DT_ItemData 할당)
UPROPERTY(EditDefaultsOnly, Category = "Item")
class UDataTable* ItemDataTable;
private:
// 실제 효과 실행 (서버 전용)
void ExecuteEffect(const FFDItemData& Data);
};
ClassGroup은 에디터 정리정돈용
BlueprintSpawnableComponent는 블루프린트에서 이 컴포넌트를 고를 수 있게 열어주는 것
// FDItemInventoryComponent.cpp
#include "Item/FDItemInventoryComponent.h"
#include "Net/UnrealNetwork.h"
#include "Engine/DataTable.h"
UFDItemInventoryComponent::UFDItemInventoryComponent()
{
PrimaryComponentTick.bCanEverTick = false;
// HeldItemRow를 복제해야 하니 컴포넌트 자체 복제를 켬
SetIsReplicatedByDefault(true);
}
void UFDItemInventoryComponent::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(UFDItemInventoryComponent, HeldItemRow);
}
void UFDItemInventoryComponent::ReceiveDrawnItem(FName ItemRow)
{
// 서버만
if (!GetOwner() || !GetOwner()->HasAuthority()) return;
if (!ItemDataTable)
{
UE_LOG(LogTemp, Warning, TEXT("[아이템 인벤토리] ItemDataTable이 할당되지 않음"));
return;
}
const FFDItemData* Data = ItemDataTable->FindRow<FFDItemData>(ItemRow, TEXT("뽑힌 아이템 조회"));
if (!Data) return;
if (Data->bStoredOnPickup)
{
// 보관형 인벤토리에 저장 - 이미 들고 있었으면 덮어씀 (개수 추가하는 거 나중에 구현해도 됨)
HeldItemRow = ItemRow;
// 서버니까 수동 호출
OnRep_HeldItemRow();
}
else
{
// 즉시발동형 저장하지 않고 바로 실행
ExecuteEffect(*Data);
}
}
void UFDItemInventoryComponent::Server_UseHeldItem_Implementation()
{
if (HeldItemRow.IsNone()) return;
if (!ItemDataTable) return;
const FFDItemData* Data = ItemDataTable->FindRow<FFDItemData>(HeldItemRow, TEXT("보관 아이템 사용"));
if (!Data) return;
// 소모 처리를 효과 실행보다 먼저 함
// 효과 실행 도중 중복 사용 요청이 들어와도 이미 HeldItemRow가 비어있어서 씹히도록
const FFDItemData UsedData = *Data; // ExecuteEffect에 넘길 값 복사 (아래서 HeldItemRow를 지우기 전에)
HeldItemRow = NAME_None;
OnRep_HeldItemRow();
ExecuteEffect(UsedData);
}
// 보관중인 Item 업데이트 될 때마다 불림
void UFDItemInventoryComponent::OnRep_HeldItemRow()
{
// 아이템 슬롯 UI 갱신
UE_LOG(LogTemp, Warning, TEXT("Item 업데이트"));
}
void UFDItemInventoryComponent::ExecuteEffect(const FFDItemData& Data)
{
UE_LOG(LogTemp, Warning, TEXT("효과 발동"));
}
const FFDItemData* Data = ItemDataTable->FindRow<FFDItemData>(ItemRow, TEXT("뽑힌 아이템 조회"));
-> ItemDataTable이라는 데이터 테이블 에셋에서(미리 선언해둠 에디터에서 할당될거임) ItemRow라는 이름을 가진 행을 찾아서 FFDItemData 타입으로 해석해서 포인터로 돌려줌
필드 아이템박스
필드에 놓인 박스는 하이더가 닿으면 서버가 가중치 뽑기를 해서 결과를 인벤토리 컴포넌트에 넘겨주고 잠깐 사라졌다가 다시 나타나는 액터
- 판정 로직: OnComponentBeginOverlap : 겹치면 하이더인지 확인 -> 인벤토리 컴포넌트 있는지 확인 -> 뽑기 실행
- 뽑기 함수: 데이터 테이블 전체 행을 훑어서 가중치 기반으로 하나 고르는 함수
// FDItemPickup.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "Item/FDItemTypes.h"
#include "FDItemPickup.generated.h"
UCLASS()
class FUNNYORDIE_API AFDItemPickup : public AActor
{
GENERATED_BODY()
public:
AFDItemPickup();
protected:
virtual void BeginPlay() override;
// 줍기 판정용 콜리전 (루트)
UPROPERTY(VisibleAnywhere, Category = "Pickup")
class USphereComponent* PickupCollision;
UPROPERTY(VisibleAnywhere, Category = "Pickup")
class UStaticMeshComponent* BoxMesh;
// 아이템 데이터 테이블
UPROPERTY(EditDefaultsOnly, Category = "Pickup")
class UDataTable* ItemDataTable;
UFUNCTION()
void OnPickupOverlap(UPrimitiveComponent* OverlappedComp, AActor* OtherActor,
UPrimitiveComponent* OtherComp, int32 OtherBodyIndex,
bool bFromSweep, const FHitResult& SweepResult);
private:
// 가중치 기반 랜덤 뽑기
FName DrawRandomItemRow() const;
// 현재 주울 수 있는 상태인지
bool bIsActive = true;
};
// FDItemPickup.cpp
#include "Item/FDItemPickup.h"
#include "Item/FDItemInventoryComponent.h"
#include "Character/FDHiderCharacter.h"
#include "Components/SphereComponent.h"
#include "Components/StaticMeshComponent.h"
#include "Engine/DataTable.h"
AFDItemPickup::AFDItemPickup()
{
PrimaryActorTick.bCanEverTick = false;
bReplicates = true; // 서버가 스폰하면 클라이언트에도 자동으로 보이게
PickupCollision = CreateDefaultSubobject<USphereComponent>(TEXT("PickupCollision"));
SetRootComponent(PickupCollision);
PickupCollision->SetSphereRadius(100.f);
PickupCollision->SetCollisionEnabled(ECollisionEnabled::QueryOnly);
BoxMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("BoxMesh"));
BoxMesh->SetupAttachment(RootComponent);
BoxMesh->SetCollisionEnabled(ECollisionEnabled::NoCollision);
}
void AFDItemPickup::BeginPlay()
{
Super::BeginPlay();
if (HasAuthority())
{
PickupCollision->OnComponentBeginOverlap.AddDynamic(
this, &AFDItemPickup::OnPickupOverlap);
}
}
void AFDItemPickup::OnPickupOverlap(UPrimitiveComponent* OverlappedComp, AActor* OtherActor,
UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
if (!bIsActive) return;
// 하이더만 주울 수 있음
AFDHiderCharacter* Hider = Cast<AFDHiderCharacter>(OtherActor);
if (!Hider) return;
UFDItemInventoryComponent* Inventory =
Hider->FindComponentByClass<UFDItemInventoryComponent>();
if (!Inventory) return;
const FName DrawnRow = DrawRandomItemRow();
if (DrawnRow.IsNone()) return;
bIsActive = false;
// 결과 처리는 인벤토리 컴포넌트에 (보관이냐 즉시발동이냐 판단도 거기서)
Inventory->ReceiveDrawnItem(DrawnRow);
// 이 박스는 역할이 끝났으니 파괴
// 리스폰(새 박스를 어디에 놓을지)은 ASpawnVolume이 타이머로 관리
Destroy();
}
FName AFDItemPickup::DrawRandomItemRow() const
{
if (!ItemDataTable) return NAME_None;
// 전체 가중치 합산
TArray<FName> RowNames = ItemDataTable->GetRowNames();
float TotalWeight = 0.f;
for (const FName& RowName : RowNames)
{
if (const FFDItemData* Data = ItemDataTable->FindRow<FFDItemData>(RowName, TEXT("뽑기 가중치 합산")))
{
TotalWeight += Data->DrawWeight;
}
}
if (TotalWeight <= 0.f) return NAME_None;
// 0~합계 사이 랜덤값이 어느 구간에 떨어지는지 찾기
float Roll = FMath::FRandRange(0.f, TotalWeight);
for (const FName& RowName : RowNames)
{
if (const FFDItemData* Data = ItemDataTable->FindRow<FFDItemData>(RowName, TEXT("뽑기 실행")))
{
Roll -= Data->DrawWeight;
if (Roll <= 0.f) return RowName;
}
}
return RowNames.Last();
}
ASpawnVolume - 구역(Box) 안 랜덤 위치를 계산해서 AFDItemPickup을 주기적으로 스폰
actor 상속해서 C++ 만들기
// FDSpawnVolume.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "FDSpawnVolume.generated.h"
class UBoxComponent;
class AFDItemPickup;
UCLASS()
class FUNNYORDIE_API AFDSpawnVolume : public AActor
{
GENERATED_BODY()
public:
AFDSpawnVolume();
protected:
virtual void BeginPlay() override;
UPROPERTY(VisibleAnywhere, Category = "Spawning")
USceneComponent* Scene;
UPROPERTY(VisibleAnywhere, Category = "Spawning")
UBoxComponent* SpawningBox;
// 스폰할 아이템 박스 클래스 - 에디터에서 AFDItemPickup 설정해야함
UPROPERTY(EditAnywhere, Category = "Spawning")
TSubclassOf<AFDItemPickup> ItemPickupClass;
// 동시에 존재할 수 있는 최대 박스 개수
UPROPERTY(EditAnywhere, Category = "Spawning")
int32 MaxActiveItems = 3;
// 몇 초마다 스폰을 시도할지
UPROPERTY(EditAnywhere, Category = "Spawning")
float SpawnInterval = 10.f;
// 구역 내부 랜덤 좌표 계산
FVector GetRandomPointInVolume() const;
private:
// 조건 확인 후 스폰 시도
void TrySpawnItem();
// 이 SpawnVolume이 둔 박스들 - 개수 추적용
// UPROPERTY로 선언해야 박스가 Destroy될 때 배열 안 포인터가 자동으로 nullptr 처리됨 (GC 연동)
UPROPERTY()
TArray<AFDItemPickup*> SpawnedItems;
FTimerHandle SpawnTimerHandle;
};
// FDSpawnVolume.cpp
#include "Item/FDSpawnVolume.h"
#include "Item/FDItemPickup.h"
#include "Components/BoxComponent.h"
AFDSpawnVolume::AFDSpawnVolume()
{
PrimaryActorTick.bCanEverTick = false;
Scene = CreateDefaultSubobject<USceneComponent>(TEXT("Scene"));
SetRootComponent(Scene);
SpawningBox = CreateDefaultSubobject<UBoxComponent>(TEXT("SpawningBox"));
SpawningBox->SetupAttachment(Scene);
SpawningBox->SetCollisionEnabled(ECollisionEnabled::NoCollision);
// 범위 표시용 (충돌 판정 없음)
}
void AFDSpawnVolume::BeginPlay()
{
Super::BeginPlay();
if (!HasAuthority()) return;
GetWorldTimerManager().SetTimer(
SpawnTimerHandle,
this,
&AFDSpawnVolume::TrySpawnItem,
SpawnInterval,
true); // 반복 실행
}
FVector AFDSpawnVolume::GetRandomPointInVolume() const
{
const FVector BoxExtent = SpawningBox->GetScaledBoxExtent();
const FVector BoxOrigin = SpawningBox->GetComponentLocation();
return BoxOrigin + FVector(
FMath::FRandRange(-BoxExtent.X, BoxExtent.X),
FMath::FRandRange(-BoxExtent.Y, BoxExtent.Y),
FMath::FRandRange(-BoxExtent.Z, BoxExtent.Z)
);
}
void AFDSpawnVolume::TrySpawnItem()
{
if (!ItemPickupClass) return;
// 현재 살아있는 박스 개수 세기 (Destroy된 것들은 nullptr이라 자동 제외됨)
int32 AliveCount = 0;
for (AFDItemPickup* Item : SpawnedItems)
{
if (IsValid(Item))
{
++AliveCount;
}
}
// 이미 가득 찼으면 건너뜀
if (AliveCount >= MaxActiveItems) return;
// 액터를 어떻게 스폰할지?
FActorSpawnParameters SpawnParams;
// 스폰하려는 위치에 다른 물체가 있다면
// 겹치면 살짝 위치를 밀어서라도 시도해보고 그래도 안 되면 그냥 억지로라도 스폰은 시켜라
SpawnParams.SpawnCollisionHandlingOverride
= ESpawnActorCollisionHandlingMethod::AdjustIfPossibleButAlwaysSpawn;
// 실제 스폰시킴
AFDItemPickup* NewItem = GetWorld()->SpawnActor<AFDItemPickup>(
ItemPickupClass,
GetRandomPointInVolume(),
FRotator::ZeroRotator,
SpawnParams);
// TrySpawnItem에서 내가 얼마나 박스를 생성했는지 알아야하니까 만든 박스가 유효하면 spawnitem에 등록하는 거
if (NewItem)
{
SpawnedItems.Add(NewItem);
}
}
에디터 작업
1. item 블루프린트로 감싸고 디테일 창에서 DT 할당하기

+ 메시 지정
2. spawn volume도 bp 감싸고 디테일 창에서 스폰될 아이템 할당

3. HiderCharacter BP에 ItemInventoryComponent를 Add Component로 붙이거나
C++에서 직접 붙여주기
UPROPERTY(VisibleAnywhere, Category = "Item")
class UFDItemInventoryComponent* ItemInventoryComp;
AFDHiderCharacter::AFDHiderCharacter()
{
CustomizationComp = CreateDefaultSubobject<UFDCustomizationComponent>(TEXT("CustomizationComp"));
ItemInventoryComp = CreateDefaultSubobject<UFDItemInventoryComponent>(TEXT("ItemInventoryComp"));
}
-> 하이더 블루프린트 가서 새로 생긴 inventory에 DT 할당

ItemData (설계도) -> DT_ItemData (실제 값) -> ItemPickup (뽑기 + Overlap 판정)
-> ItemInventoryComponent (보관 vs 즉시발동)
-> SpawnVolume (위치/타이밍 관리)
하이더가 박스에 접근
-> PickupCollision에서 Overlap 발생
(서버에서만 바인딩된 콜백이 실행됨 - CaptureCollision과 같은 원칙)
-> OnPickupOverlap()
OtherActor가 HiderCharacter인지 확인
그 하이더가 ItemInventoryComponent를 갖고 있는지 확인
-> DrawRandomItemRow() = 가중치 뽑기, 여기서 Row Name 하나 결정
-> Inventory->ReceiveDrawnItem(뽑힌 RowName)
-> 박스 비활성화 + 일정 시간 후 리스폰
'프로젝트 > Funny Or Die (멀티플레이 술래잡기 게임)' 카테고리의 다른 글
| 투명화 아이템 사용 기능 구현 (Z키) (0) | 2026.07.10 |
|---|---|
| 인벤토리 개편 (0) | 2026.07.10 |
| 인트로 화면 구성 (0) | 2026.07.07 |
| 에셋 (0) | 2026.07.07 |
| EndMatch 구현 (게임 한 바퀴 완주) (0) | 2026.07.03 |
