TIL
06.26 TIL
2026. 6. 26. 20:15

일정표

시간 할 일 비고
08:00~10:00 코드카타, 디자인 패턴  
10:00~13:00 오전 팀플 + 언리얼 게임 만들기  
14:00~18:00 오후 스크럼, 오후 팀플  
19:00~21:00 저녁 팀플, 저녁 스크럼, TIL  
21:00~22:00 운동  
22:00~23:30 개인 공부  

*오늘의 코드카타*

 

문제. K번째 수

더보기

배열 array의 i번째 숫자부터 j번째 숫자까지 자르고 정렬했을 때, k번째에 있는 수를 구하려 합니다

예를 들어 array가 [1, 5, 2, 6, 3, 7, 4], i = 2, j = 5, k = 3이라면

array의 2번째부터 5번째까지 자르면 [5, 2, 6, 3]입니다.

1에서 나온 배열을 정렬하면 [2, 3, 5, 6]입니다.

2에서 나온 배열의 3번째 숫자는 5입니다.

배열 array, [i, j, k]를 원소로 가진 2차원 배열 commands가 매개변수로 주어질 때, commands의 모든 원소에 대해 앞서 설명한 연산을 적용했을 때 나온 결과를 배열에 담아 return 하도록 solution 함수를 작성해주세요

#include <string>
#include <vector>
#include <algorithm>

using namespace std;

vector<int> solution(vector<int> array, vector<vector<int>> commands) {
    vector<int> answer;
    
    for (int idx = 0; idx < commands.size(); idx++) {
        int i = commands[idx][0] - 1;
        int j = commands[idx][1];
        int k = commands[idx][2] - 1;
        vector<int> sub(array.begin() + i, array.begin() + j);
        sort(sub.begin(), sub.end());
        answer.push_back(sub[k]);
    }
    
    return answer;
}

이전에 풀었던 문제. 문제 요구사항을 그대로 따라가면서 풀이했다.


*팀 프로젝트*

1. 디버그 로그 관리하기

 

매번 코드를 찾아다니며 UE_LOG를 지우는 것은 불가능에 가깝고, 실수로 중요한 코드를 건드릴 위험도 있다. 현업에서는 주로 두 가지 방법을 사용하여 로그를 제어한다.

 

방법 A: 조건부 디버그 매크로 만들기

 

프로젝트 공용 헤더 파일인 DeliveryTypes.h나 별도의 전용 헤더에 우리만의 로그 매크로를 정의하는 방법.

 

<1단계: DeliveryTypes.h 상단에 매크로 정의하기>

#pragma once

#include "CoreMinimal.h"
#include "Engine/DataTable.h"
// ... 기존 인클루드 생략 ...

// ★ 디버그 로그 활성화 스위치 (1: 켬, 0: 완전히 끔)
#define ENABLE_DELIVERY_DEBUG_LOG 1

#if ENABLE_DELIVERY_DEBUG_LOG
    // 로그가 켜져 있을 때는 정상적으로 UE_LOG를 동작.
    #define DELIVERY_LOG(Verbosity, Format, ...) UE_LOG(LogTemp, Verbosity, Format, ##__VA_ARGS__)
#else
    // 로그를 끄면 컴파일러가 이 한 줄을 빈칸으로 취급하여 빌드 시 코드가 완전히 증발.
    #define DELIVERY_LOG(Verbosity, Format, ...)
#endif

 

<2단계: 실제 코드에서 사용하기>

 

이제 소스코드에서는 UE_LOG 대신 우리가 만든 DELIVERY_LOG를 사용한다.

void ADeliveryBox::InitializeBox(int32 InBoxID, const FBoxData& InBoxData)
{
    // ... 초기화 로직 ...

    // 우리가 만든 매크로 적용
    DELIVERY_LOG(Log, TEXT("Box Initialized - ID: %d"), BoxID); 
}

 

 

이 방식의 장점: 나중에 라이브 출시(Shipping) 빌드를 하거나 로그가 귀찮아지면, #define ENABLE_DELIVERY_DEBUG_LOG 0으로 숫자만 0으로 바꾸고 빌드하면 된다. 수백 개의 로그 코드가 단 1초 만에 깔끔하게 비활성화되며 성능 오버헤드도 0이 된다.

 

 

방법 B: 언리얼 커스텀 로그 카테고리 선언 (에디터 제어 방식)

 

언리얼 엔진 자체 기능인 '로그 카테고리'를 파트별로 분리하여 에디터 콘솔 창에서 끄고 켜는 방법.

LogTemp를 쓰면 다른 임시 로그들과 섞여서 보기 힘들기 때문에 이 방식을 현업에서 표준으로 쓴다.

 

<1단계: 헤더 파일(DeliveryBox.h 등)에 선언>

// 우리 파트 전용 로그 카테고리 이름 정의
DECLARE_LOG_CATEGORY_EXTERN(LogParcelDelivery, Log, All);

 

<2단계: 소스 파일(DeliveryBox.cpp 등) 최상단에 등록>

DEFINE_LOG_CATEGORY(LogParcelDelivery);

 

<3단계: 코드에서 사용>

DELIVERY_LOG(LogParcelDelivery, Warning, TEXT("충돌 발생! 충격량: %f"), ImpactForce);

 

<4단계: 에디터 콘솔이나 설정 파일로 끄기>

 

코드를 건드리지 않고 게임 실행 중 콘솔 창(~ 키)에 아래 명령어를 치면 우리 파트 로그만 화면에서 사라진다.

 

Log LogParcelDelivery none (로그 끄기)

Log LogParcelDelivery log (로그 다시 켜기)


2. 코드 리뷰 및 작성

#pragma once

#include "CoreMinimal.h"
#include "Engine/DataTable.h"
#include "GameplayTagContainer.h"
#include "DeliveryTypes.generated.h"

DECLARE_LOG_CATEGORY_EXTERN(LogDelivery, Log, All);

class UStaticMesh;

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

	/** 택배 고유 종류 태그. 에디터 내에서 RowName과 일치시켜 관리 */
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Delivery Box Data")
	FGameplayTag BoxTypeTag;

	/** UI 및 화면에 표시할 이름 */
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Delivery Box Data")
	FString DisplayName;

	/** 배송 성공 시 기본 점수 */
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Delivery Box Data")
	int32 BaseScore = 1;

	/** 물리 연산 및 무게감에 적용할 무게 값 */
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Delivery Box Data")
	float Weight = 1.0f;

	/** 해당 상자를 들었을 때 플레이어에게 적용할 이동 속도 배율 */
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Delivery Box Data")
	float MoveSpeedMultiplier = 1.0f;

	/** 파손 판정을 유도할 물리 충격량 임계값. 파손주의 박스는 임계값을 150.0f 정도로 낮게. 튼튼한 택배는 800.0f 정도로*/
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Delivery Box Data")
	float DamageThreshold = 500.0f;

	/** 파손되었을 때 차감될 점수 페널티 */
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Delivery Box Data")
	int32 DamagePenalty = -1;

	/** 택배 상자의 외형 에셋 */
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Delivery Box Data")
	TObjectPtr<UStaticMesh> BoxMeshAsset;
};
#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "GameplayTagContainer.h"
#include "DeliveryTypes.h"
#include "Delivery/Carryable.h"
#include "DeliveryBox.generated.h"

class UBoxComponent;
class UStaticMeshComponent;

UCLASS()
class PARCEL_KNIGHT_API ADeliveryBox : public AActor, public ICarryable
{
	GENERATED_BODY()
	
public:	
	ADeliveryBox();

protected:
    virtual void BeginPlay() override;
    virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;

public:
    /** 서버에서 안전하게 상태 태그를 제어하는 권한 전용 함수들 */
    void AddStateTag(FGameplayTag NewStateTag);
    void RemoveStateTag(FGameplayTag StateTag);
	
    void InitializeBox(int32 InBoxID, const FBoxData& InBoxData);

    /** [Interface] ICarryable 오버라이드 */
    virtual bool CanCarry(AActor* Carrier) override;
    virtual void OnPickedUp(AActor* Carrier) override;
    virtual void OnDropped() override;

    /** Getter 함수. 상자 ID, Tag, BoxData, 물리 임계값 */
	FORCEINLINE bool HasStateTag(FGameplayTag StateTag) const;
	
    FORCEINLINE int32 GetBoxID() const { return BoxID; }
    FORCEINLINE FGameplayTagContainer GetBoxStateTags() const { return BoxStateTags; }
    FORCEINLINE FBoxData GetBoxData() const { return BoxData; }
    FORCEINLINE float GetDamageThreshold() const { return BoxData.DamageThreshold; }
	
protected:
	/** [Client] 태그 변경 시 클라이언트 연출용 RepNotify */
	UFUNCTION()
	void OnRep_BoxStateTags();

	/** [Client] 데이터 설정 시 클라이언트 Mesh 동기화용 RepNotify */
	UFUNCTION()
	void OnRep_BoxData();
	
	/** [Server] 서버에서 호출될 파손 판정용 콜백 함수 */
	UFUNCTION()
	void OnPhysicsHit(
		UPrimitiveComponent* HitComponent, 
		AActor* OtherActor, UPrimitiveComponent* OtherComp,
		FVector NormalImpulse,
		const FHitResult& Hit);
	
private:
	/** 컴포넌트 */
	UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Components", meta = (AllowPrivateAccess = "true"))
	TObjectPtr<UBoxComponent> CollisionComponent;
	
	UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Components", meta = (AllowPrivateAccess = "true"))
	TObjectPtr<UStaticMeshComponent> BoxMesh;
	
	/* ==========================================================================
		   동기화 규칙 변수 (Replicated)
	========================================================================== */
	UPROPERTY(Replicated)
	int32 BoxID;
	
	UPROPERTY(ReplicatedUsing = OnRep_BoxStateTags)
	FGameplayTagContainer BoxStateTags;
	
	UPROPERTY(ReplicatedUsing = OnRep_BoxData)
	FBoxData BoxData;
	
	UPROPERTY(Replicated)
	TObjectPtr<APlayerController> HolderPlayer;
};
#include "Delivery/DeliveryBox.h"
#include "Components/StaticMeshComponent.h"
#include "Components/BoxComponent.h"
#include "Net/UnrealNetwork.h"
//Todo: #include "UPakageDamageManager.h"

DEFINE_LOG_CATEGORY(LogDelivery);

ADeliveryBox::ADeliveryBox()
{
	PrimaryActorTick.bCanEverTick = false;
	bReplicates = true;
	SetReplicateMovement(true); // 리슨 서버 물리 동기화 활성화

	// 박스 콜리전 생성 >> 물리 바디 콜리전 세팅 >> Mesh 부착 후 자체 물리 Off
	CollisionComponent = CreateDefaultSubobject<UBoxComponent>(TEXT("CollisionComponent"));
	RootComponent = CollisionComponent;
	
	CollisionComponent->SetSimulatePhysics(true);
	CollisionComponent->SetCollisionProfileName(TEXT("PhysicsBody"));
	CollisionComponent->SetNotifyRigidBodyCollision(true);
	
	BoxMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("BoxMesh"));
	BoxMesh->SetupAttachment(RootComponent);
	BoxMesh->SetCollisionEnabled(ECollisionEnabled::NoCollision);
	BoxMesh->SetSimulatePhysics(false);
	
	BoxID = -1;
}

void ADeliveryBox::BeginPlay()
{
	Super::BeginPlay();
	
	if (HasAuthority() && CollisionComponent)
	{
		CollisionComponent->OnComponentHit.AddDynamic(this, &ADeliveryBox::OnPhysicsHit);
	}
}

void ADeliveryBox::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
	Super::GetLifetimeReplicatedProps(OutLifetimeProps);

	DOREPLIFETIME(ADeliveryBox, BoxID);
	DOREPLIFETIME(ADeliveryBox, BoxStateTags);
	DOREPLIFETIME(ADeliveryBox, BoxData);
	DOREPLIFETIME(ADeliveryBox, HolderPlayer);
}

void ADeliveryBox::InitializeBox(int32 InBoxID, const FBoxData& InBoxData)
{
	if (!HasAuthority()) return;

	BoxID = InBoxID;
	BoxData = InBoxData;
	
	if (BoxData.BoxMeshAsset && BoxMesh && CollisionComponent)
	{
		BoxMesh->SetStaticMesh(BoxData.BoxMeshAsset);
		// 무게 적용 (밸런싱 수치 조절 (현재 1.0f))
		CollisionComponent->SetMassOverrideInKg(NAME_None, BoxData.Weight * 1.0f, true);
	}
	// Spawned 태그 부여
	AddStateTag(FGameplayTag::RequestGameplayTag(TEXT("Box.State.Spawned")));
	
	UE_LOG(LogDelivery, Log, TEXT("[Server] Box %d (Type: %s) Initialized and spawned successfully."), 
		BoxID, *BoxData.BoxTypeTag.ToString());
}

void ADeliveryBox::OnRep_BoxData()
{
	// 클라이언트 측에서도 서버가 채워준 BoxData를 받으면 메시 외형을 동기화함
	if (BoxData.BoxMeshAsset && BoxMesh)
	{
		BoxMesh->SetStaticMesh(BoxData.BoxMeshAsset);
	}
	
	UE_LOG(LogDelivery, Display, TEXT("[Client] Received updated BoxStateTags for Box %d. Tags: %s"), 
		BoxID, *BoxStateTags.ToString());
}

void ADeliveryBox::AddStateTag(FGameplayTag NewStateTag)
{
	if (!HasAuthority() || !NewStateTag.IsValid()) return;

	if (!BoxStateTags.HasTagExact(NewStateTag))
	{
		BoxStateTags.AddTag(NewStateTag);
		
		//플레이어가 상자를 들면 상자의 물리 규칙은 잠시 꺼야 함.
		if (NewStateTag.MatchesTag(FGameplayTag::RequestGameplayTag(TEXT("Box.State.Held"))))
		{
			CollisionComponent->SetSimulatePhysics(false);
			CollisionComponent->SetCollisionEnabled(ECollisionEnabled::NoCollision);
		}
	}
}

void ADeliveryBox::RemoveStateTag(FGameplayTag StateTag)
{
	if (!HasAuthority() || !StateTag.IsValid()) return;

	if (BoxStateTags.HasTagExact(StateTag))
	{
		BoxStateTags.RemoveTag(StateTag);
        
		// 상태가 제거될 때의 예외 복구 로직
		if (StateTag.MatchesTag(FGameplayTag::RequestGameplayTag(TEXT("Box.State.Held"))))
		{
			CollisionComponent->SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics);
			CollisionComponent->SetSimulatePhysics(true);
		}
	}
}

bool ADeliveryBox::HasStateTag(FGameplayTag StateTag) const
{
	return BoxStateTags.HasTag(StateTag);
}

void ADeliveryBox::OnRep_BoxStateTags()
{
	// [Client] 시각/청각 연출 분기점
	FGameplayTag HeldTag = FGameplayTag::RequestGameplayTag(TEXT("Box.State.Held"));
	FGameplayTag DamagedTag = FGameplayTag::RequestGameplayTag(TEXT("Box.State.Damaged"));

	if (BoxStateTags.HasTag(HeldTag))
	{
		CollisionComponent->SetSimulatePhysics(false);
		CollisionComponent->SetCollisionEnabled(ECollisionEnabled::NoCollision);
	}
	else
	{
		CollisionComponent->SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics);
		CollisionComponent->SetSimulatePhysics(true);
	}

	if (BoxStateTags.HasTag(DamagedTag))
	{
		// Todo : 찌그러진 메쉬로 교체하거나 내용물이 튀어나오는 이펙트/사운드 호출
	}
}

/* ==========================================================================
   ICarryable 인터페이스
   ========================================================================== */

bool ADeliveryBox::CanCarry(AActor* Carrier)
{
	// 월드에 생성되어 놓여있으면 집기 가능 허용 (여러 상태 이상일 때 못집게 할 수 있음)
	return BoxStateTags.HasTagExact(FGameplayTag::RequestGameplayTag(TEXT("Box.State.Spawned")));
}

void ADeliveryBox::OnPickedUp(AActor* Carrier)
{
	if (!HasAuthority() || !Carrier) return;
	
	APawn* CarrierPawn = Cast<APawn>(Carrier);
	if (CarrierPawn)
	{
		HolderPlayer = Cast<APlayerController>(CarrierPawn->GetController());
		SetOwner(CarrierPawn); // 리슨 서버 소유권(Owner) 변경
	}

	RemoveStateTag(FGameplayTag::RequestGameplayTag(TEXT("Box.State.Spawned")));
	AddStateTag(FGameplayTag::RequestGameplayTag(TEXT("Box.State.Held")));
}

void ADeliveryBox::OnDropped()
{
	if (!HasAuthority()) return;

	HolderPlayer = nullptr;
	SetOwner(nullptr); // 소유권 월드로 반환

	RemoveStateTag(FGameplayTag::RequestGameplayTag(TEXT("Box.State.Held")));
	AddStateTag(FGameplayTag::RequestGameplayTag(TEXT("Box.State.Spawned")));
}

void ADeliveryBox::OnPhysicsHit(UPrimitiveComponent* HitComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, FVector NormalImpulse, const FHitResult& Hit)
{
	if (!HasAuthority()) return;
	if (HasStateTag(FGameplayTag::RequestGameplayTag(TEXT("Box.State.Damaged")))) return;
	if (HasStateTag(FGameplayTag::RequestGameplayTag(TEXT("Box.State.Held")))) return;

	float ImpactForce = NormalImpulse.Size();
	if (ImpactForce < 100.0f) return;
	
	UE_LOG(LogDelivery, Warning, TEXT("[Server] Box %d Collision Detected! Counterpart: %s, Impact Force: %f"), 
		BoxID, OtherActor ? *OtherActor->GetName() : TEXT("None"), ImpactForce);

	if (UWorld* World = GetWorld())
	{
		// 5번 팀원의 매니저 서브시스템이 연결되면 사용
		/*
		if (UPackageDamageManager* DamageManager = World->GetSubsystem<UPackageDamageManager>())
		{
		   DamageManager->EvaluatePackageDamage(this, ImpactForce, OtherActor);
		}
		*/
	}
}

1) 생성자에 있었던 CreateDefaultSubobject 오타를 발견해서 수정.

2) DECLARE_LOG_CATEGORY_EXTERN 및 DEFINE_LOG_CATEGORY를 사용하여 LogDelivery 로그 카테고리를 사용. 콘솔 창에서 ~를 통해 이 파트의 로그만 끄고 켤 수 있음.

3) FORCEINLINE으로 선언된 게터 함수들의 구현부가 헤더 파일 내부에 인라인으로 위치

 

4) 데이터 테이블에서 파싱된 FBoxData를 서버에서 주입받아 상자의 스펙을 확정한다. 기획에 따라 설정된 무게를 반영하여 물리 컴포넌트의 실제 뭄게를 변경하기 위해 CollisionComponent->SetMassOverrideInKg() 로직을 구현했다.

5) 게임플레이 태그 기반 상태 및 물리 제어 방식을 도입했다. 상자를 들면(Box.State.Held) 물리와 콜리전을 꺼서 캐릭터에게 부드럽게 붙도록 하고, 상자를 놓으면 다시 물리와 콜리전을 켜서 월드에 떨어지도록 설계했다.

6) ICarryable 인터페이스를 받아와서 Carrier가 플레이어인지 검증하고 GetController()를 통해 APlayerController를 캐싱하여 HolderPlayer에 저장하도록 했다.

 

7) GetLifetimeReplicatedProps는 언리얼 엔진의 멀티플레이어 환경에서 "이 클래스가 가진 변수 중 어떤 것들을 네트워크 선을 타고 클라이언트 컴퓨터로 동기화 할 것인지"를 등록하는 전용 등록소이다. 내부에 작성된 DOREPLIFETIME(ADeliveryBox, 변수명); 매크로를 통해서 등록된 변수들은 서버에서 값이 바뀌는 순간 모든 클라이언트의 메모리로 자동 복제된다.

8) 모든 중요 로직 최상단에 if (!HasAuthority()) return; 방어 코드를 배치하여 클라이언트의 임의 변조를 차단하였다.

9) OnPhysicsHit 내부에서 직접 Damaged 태그를 붙이지 않고, 충격량 벡터의 크기(NormalImpulse.Size())를 계산한 뒤 월드 서브시스템으로 토스하도록 경로만 확보해 두었다. 따라서 택배 스스로 파손을 판단하지 않는다.

10) EBoxState enum 대신에 FGameplayTagContainer(BoxStateTags)로, 택배 Actor는 상태만 가지도록 하고 상자의 모든 다중 상태를 가볍게 관리하도록 구현했다.

11) OnPickedUp 함수 내부에서 SetOwner(CarrierPawn); 을 호출해서 리슨 서버 환경에서 상자를 집은 플레이어에게 해당 액터의 네트워크 소유권을 명확히 넘겨주었다.

12) OnPhysicsHit 내부에서 ImpactForce < 100.0f 조건문을 통해 바닥에 살살 내려놓으면 충격량을 계산할 필요 없도록 필터링했다.

13) 서버가 판정한 상태인 BoxStateTags가 복제되어 넘어오는 순간, 클라이언트 PC에서는 OnRep_BoxStateTags()가 자동으로 실행되어 메쉬 및 이펙트 분기를 탈 수 있도록 뼈대를 만들어 두었다.


 

3. 인터페이스, 충돌 매니저 이해하기

1) IInteractable, UInteractionComponent : 눈으로 조준하고 상호작용하기.

IInteractable 인터페이스를 상속받은 오브젝트는 상호작용이 가능하다고 선언하는 규칙. 상호작용이 가능한지 검사하는 CanInteract와 실제 행동을 수행하는 Interact함수가 있음.

UInteractionComponent : TickComponent에서 매 프레임 CheckTraceTarget()을 실행하고, TraceDistance만큼 LineTraceSingleByChannel을 실행한다. Ray에 맞은 액터가 IInteractable 인터페이스를 가지고 있다면 그 액터를 CurrentFocusedActor에 임시 저장해둔다. 이후 플레이어가 상호작용 키를 누르면 PrimaryInteract()가 실행되고 오브젝트의 Interact()함수를 원격으로 실행시킨다.

 

2) ICarryable, UCarryComponent : 캐릭터가 상자를 집어서 손에 붙이고, 들고 이동하는 물리적 처리 담당.

ICarryable : 들 수 있는 오브젝트가 상속받아야 하는 인터페이스. 집을 수 있는지 검사하는 CanCarry, 집었을 때의 처리인 OnPickedUp, 놓았을 때의 처리인 OnDropped 함수가 있음.

UCarryComponent : 캐릭터의 양손 역할을 하는 컴포넌트. 상자를 집어드는 함수인 PickUpBox(InBox)가 있고, 캐릭터 메시의 HandSocket위치에 상자를 강제로 AttachToComponent하여 붙여버림. 이후 MaxWalkSpeed를 저하시킨다. DropBox()는 반대로 DetachFromActor를 실행하고 캐릭터의 이동속도를 복구한다.

 

3) UDeliverySubsystem, UPhysicsJudgeManager : 레벨이 켜져 있는 동안 백그라운드에서 상자를 관리하는 레이어.

UDeliverySubsystem : 상자의 탄생과 소멸을 담당한다. GenerateBoxID()로 상자가 스폰될 때 BoxID 고유 식별 번호를 발급하고 NextBoxID는 1,000번부터 시작해 하나씩 증가한다. SpawnBox와 DespawnBox가 있다.

UPhysicsJudgeManager : 상자의 파손 여부를 계산하는 역할. 상자가 부딪혀서 ImpactForce가 들어오면 내부 기준선인 500.0f 와 비교한 후 기준치를 넘으면 상자를 Damaged 상태로 바꾸는 EvaluateImpact함수가 있다.

 

 

 

4. 코드 수정

 

[1. Tick 사용 제거]

bCanEverTick을 끄고, FTimerManager를 통해 0.1초마다 한 번씩만 레이저를 쏘도록 최적화하자고 제안하였다. CPU 연산량은 90% 이상 절감된다.

 

(InteractionComponent.h/cpp 수정 후)

#pragma once

#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "InteractionComponent.generated.h"

UCLASS(ClassGroup=(Custom), meta=(BlueprintSpawnableComponent))
class PARCEL_KNIGHT_API UInteractionComponent : public UActorComponent
{
    GENERATED_BODY()

public: 
    UInteractionComponent();

protected:
    virtual void BeginPlay() override;

public: 
    // 기획서 규칙 반영: 매 프레임 도는 TickComponent 제거!
    void PrimaryInteract();

private:
    void CheckTraceTarget();

private:
    UPROPERTY()
    AActor* CurrentFocusedActor;

    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Interaction", meta = (AllowPrivateAccess = "true"))
    float TraceDistance;

    // 틱 대신 주기를 관리할 타이머 핸들 추가
    FTimerHandle TraceTimerHandle;
};
#include "Delivery/InteractionComponent.h"
#include "Delivery/Interactable.h"
#include "GameFramework/Character.h"
#include "GameFramework/PlayerController.h"
#include "CollisionQueryParams.h"

UInteractionComponent::UInteractionComponent()
{
    // 최적화: 틱을 완전히 비활성화합니다.
    PrimaryComponentTick.bCanEverTick = false;
    TraceDistance = 300.f;
    CurrentFocusedActor = nullptr;
}

void UInteractionComponent::BeginPlay()
{
    Super::BeginPlay();

    // 틱 대신 0.1초마다 CheckTraceTarget 함수를 호출하는 타이머 가동 (GDD 최적화 규칙)
    if (GetWorld())
    {
        GetWorld()->GetTimerManager().SetTimer(TraceTimerHandle, this, &UInteractionComponent::CheckTraceTarget, 0.1f, true);
    }
}

void UInteractionComponent::CheckTraceTarget()
{
    ACharacter* OwnerCharacter = Cast<ACharacter>(GetOwner());
    if (!OwnerCharacter) return;

    APlayerController* PC = Cast<APlayerController>(OwnerCharacter->GetController());
    if (!PC) return;

    FVector TraceStart;
    FRotator TraceRotation;
    PC->GetPlayerViewPoint(TraceStart, TraceRotation);

    FVector TraceEnd = TraceStart + (TraceRotation.Vector() * TraceDistance);

    FHitResult HitResult;
    FCollisionQueryParams QueryParams;
    QueryParams.AddIgnoredActor(OwnerCharacter);

    if (GetWorld()->LineTraceSingleByChannel(HitResult, TraceStart, TraceEnd, ECC_Visibility, QueryParams))
    {
        AActor* HitActor = HitResult.GetActor();
       
        if (HitActor && HitActor->GetClass()->ImplementsInterface(UInteractable::StaticClass()))
        {
            if (CurrentFocusedActor != HitActor)
            {
                CurrentFocusedActor = HitActor;
            }
            return;
        }
    }

    CurrentFocusedActor = nullptr;
}

void UInteractionComponent::PrimaryInteract()
{
    if (!CurrentFocusedActor) return;

    ACharacter* OwnerCharacter = Cast<ACharacter>(GetOwner());
    if (!OwnerCharacter) return;

    IInteractable* Interactable = Cast<IInteractable>(CurrentFocusedActor);
    if (Interactable && Interactable->CanInteract(OwnerCharacter))
    {
        Interactable->Interact(OwnerCharacter);
    }
}

 

[2. 인터페이스 바인딩 + Server RPC 추가]

멀티플레이 환경에서 클라이언트가 상자를 집었을 때 모든 유저의 화면에 동기화되도록 Server RPC를 구축. 또한, 하드코딩으로 줄어들던 이동 속도를 상자의 고유 데이터(MoveSpeedMultiplier) 기반으로 작동하도록 수정.

#pragma once

#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "CarryComponent.generated.h"

UCLASS(ClassGroup=(Custom), meta=(BlueprintSpawnableComponent))
class PARCEL_KNIGHT_API UCarryComponent : public UActorComponent
{
    GENERATED_BODY()

public: 
    UCarryComponent();

public: 
    // 클라이언트/서버 공용 입력 진입 함수
    void PickUpBox(AActor* InBox);
    void DropBox();

protected:
    // 멀티플레이어 확장을 위한 필수 Server RPC 선언
    UFUNCTION(Server, Reliable, WithValidation)
    void Server_PickUpBox(AActor* InBox);

    UFUNCTION(Server, Reliable)
    void Server_DropBox();

private:
    UPROPERTY()
    AActor* CurrentCarryingBox;
};
#include "Delivery/CarryComponent.h"
#include "Delivery/DeliveryBox.h" // 택배 박스 헤더 연결
#include "Delivery/Carryable.h"
#include "GameFramework/Character.h"
#include "GameFramework/CharacterMovementComponent.h"

UCarryComponent::UCarryComponent()
{
    PrimaryComponentTick.bCanEverTick = false;
    CurrentCarryingBox = nullptr;
}

void UCarryComponent::PickUpBox(AActor* InBox)
{
    if (!InBox) return;

    // 기획서 규칙 반영: "모든 플레이어 행동은 Request(RPC) 함수로 시작한다"
    if (GetOwner()->HasAuthority())
    {
        Server_PickUpBox_Implementation(InBox);
    }
    else
    {
        Server_PickUpBox(InBox);
    }
}

bool UCarryComponent::Server_PickUpBox_Validate(AActor* InBox)
{
    if (!InBox) return false;

    // 서버 검증: 실제로 들 수 있는 상자인지 인터페이스 규칙 확인
    ICarryable* CarryableBox = Cast<ICarryable>(InBox);
    return CarryableBox && CarryableBox->CanCarry(Cast<AActor>(GetOwner()));
}

void UCarryComponent::Server_PickUpBox_Implementation(AActor* InBox)
{
    ACharacter* OwnerCharacter = Cast<ACharacter>(GetOwner());
    if (!OwnerCharacter) return;

    CurrentCarryingBox = InBox;

    // 1. 싱크 미스 해결: 내 상자 액터에게 들렸다는 인터페이스 신호 전송 (상태 태그 및 물리 제어 가동)
    ICarryable* CarryableBox = Cast<ICarryable>(InBox);
    if (CarryableBox)
    {
        CarryableBox->OnPickedUp(OwnerCharacter);
    }

    // 2. 캐릭터 손 소켓에 부착 (서버에서 부착하면 클라이언트에 자동 동기화됨)
    FAttachmentTransformRules AttachmentRules(EAttachmentRule::SnapToTarget, EAttachmentRule::SnapToTarget, EAttachmentRule::KeepWorld, false);
    InBox->AttachToComponent(OwnerCharacter->GetMesh(), AttachmentRules, TEXT("HandSocket"));

    // 3. 기획서 데이터 테이블 연동: 하드코딩 0.6f 제거하고 상자 고유의 속도 배율 반영
    if (ADeliveryBox* DeliveryBox = Cast<ADeliveryBox>(InBox))
    {
        if (UCharacterMovementComponent* Movement = OwnerCharacter->GetCharacterMovement())
        {
            Movement->MaxWalkSpeed = 600.f * DeliveryBox->GetBoxData().MoveSpeedMultiplier;
        }
    }
}

void UCarryComponent::DropBox()
{
    if (!CurrentCarryingBox) return;

    if (GetOwner()->HasAuthority())
    {
        Server_DropBox_Implementation();
    }
    else
    {
        Server_DropBox();
    }
}

void UCarryComponent::Server_DropBox_Implementation()
{
    if (!CurrentCarryingBox) return;

    // 1. 손에서 떼어내기
    CurrentCarryingBox->DetachFromActor(FDetachmentTransformRules::KeepWorldTransform);

    // 2. 내 상자 액터에게 놓였다는 신호 전송 (물리 시뮬레이션 복구)
    ICarryable* CarryableBox = Cast<ICarryable>(CurrentCarryingBox);
    if (CarryableBox)
    {
        CarryableBox->OnDropped();
    }

    // 3. 캐릭터 이동 속도 원복
    ACharacter* OwnerCharacter = Cast<ACharacter>(GetOwner());
    if (OwnerCharacter)
    {
        if (UCharacterMovementComponent* Movement = OwnerCharacter->GetCharacterMovement())
        {
            Movement->MaxWalkSpeed = 600.f; 
        }
    }

    CurrentCarryingBox = nullptr;
}

 

[3. 하드코딩 제거 + 게임플레이 태그 싱크]

Enum 구조체를 사용하지 않고 Tags를 사용하기로 했으므로, UPhysicsJudgeManager를 리팩토링 한다.

(PhysicsJudgeManager.cpp)

#include "Delivery/PhysicsJudgeManager.h"
#include "Delivery/DeliveryBox.h" // 택배 박스 헤더 연결
#include "GameplayTagContainer.h"

void UPhysicsJudgeManager::EvaluateImpact(ADeliveryBox* Box, float ImpactForce)
{
    if (!Box) return;

    // 판정은 오직 권한이 있는 서버(호스트)에서만 독점 실행
    if (GetWorld() && GetWorld()->GetNetMode() == NM_Client) return;

    // 싱크 미스 해결: 하드코딩 500.f를 지우고,  상자 고유의 파손 임계값을 로드.
    float DamageThreshold = Box->GetDamageThreshold(); 

    if (ImpactForce >= DamageThreshold)
    {
        // 싱크 미스 해결: Enum 대신 '게임플레이 태그 상태 부여' 함수를 호출.
        Box->AddStateTag(FGameplayTag::RequestGameplayTag(TEXT("Box.State.Damaged")));
       
        UE_LOG(LogDelivery, Warning, TEXT("[Server Judge] 택배 ID %d번이 %f의 충격으로 파손되었습니다! (임계값: %f)"), 
            Box->GetBoxID(), ImpactForce, DamageThreshold);
    }
}

void UPhysicsJudgeManager::EvaluateTrapImpact(ADeliveryBox* Box, float TrapImpactForce)
{
    if (!Box) return;
    if (GetWorld() && GetWorld()->GetNetMode() == NM_Client) return;

    // 함정 직접 피격 시에도 동일하게 판정할 수 있도록 유연하게 설계됨
    if (ImpactForce >= Box->GetDamageThreshold())
    {
        Box->AddStateTag(FGameplayTag::RequestGameplayTag(TEXT("Box.State.Damaged")));
        UE_LOG(LogDelivery, Warning, TEXT("[Server Judge] 함정 직접 충격으로 택배 ID %d번 파손!"), Box->GetBoxID());
    }
}

 

수정 뒤의 전반적인 아키텍처 흐름은 다음과 같다.

 

1. 플레이어가 상자를 조준하면 타입 기반 타이머가 조준을 감지한다.

2. E키를 눌러 집으면 클라이언트가 서버에 Server RPC 요청을 보낸다.

3. 서버가 허가하는 순간 상자는 손에 달라붙고, 상자가 가진 무게 데이터에 맞춰 플레이어 속도가 유동적으로 감소한다.

4. 상자가 강하게 튕기면 서버의 충돌 이벤트가 작동하여 물리 매니저 서브시스템데이터 테이블의 임계값과 비교파손 태그를 최종 부여한다.


5. GameplayTags 추가

 

1) Project Setting -> GameplayTags 세팅에서 먼저 Gameplay Source를 추가했음. 이름은 DeliveryGameplayTags. ini 파일 형식으로 config 폴더에 저장됨을 확인

2) 에디터에서 Manage Gameplay Tags 버튼을 누르고, Add (+) 버튼을 누른 뒤에 Name, Source(DeliveryGameplayTags)를 선택하고 간단한 코멘트를 덧붙여 태그를 생성.

3) Delivered, Spawned, Held, Damaged 태그를 생성했음.

4) (추가) ini파일을 메모장이나 편집기로 열고, 다음과 같이 그대로 작성한 후 저장해도 태그를 정상적으로 사용할 수 있음

더보기
[/Script/GameplayTags.GameplayTagsSettings]
+GameplayTagList=(Tag="Box.State.Spawned",DevComment="상자가 월드에 스폰되어 배치된 상태")
+GameplayTagList=(Tag="Box.State.Held",DevComment="플레이어가 상자를 들고 있는 상태")
+GameplayTagList=(Tag="Box.State.Damaged",DevComment="상자가 물리 충격으로 인해 파손된 상태")

 

6. 추가 작업

PrimaryDataAsset을 사용하여 스테이지 매니지먼트를 위한 데이터 에셋을 만들었다.

 

UPrimaryDataAsset엔진의 에셋 매니저가 직접 관리하는 핵심 데이터이며, 소프트 포인터 및 ID를 참조한다(Async Loading 방식). 따라서 에셋의 실제 알맹이를 로드하지 않고, 고유한 ID만 가볍게 들고 다니다가 필요할 때 비동기(Async Load)로 로드할 수 있다. 스테이지, 아이템, 퀘스트 등 게임의 핵심 규칙을 담고 있으며 레벨이 바뀔 때 동적으로 로드 되어야 하는 대형 데이터에 적합하다.

 

UDataAsset은 단순한 데이터 저장용으로, 하드 포인터 참조 방식을 사용한다. 따라서 액터나 블루프린트가 이 에셋을 변수로 들고 있으면, 그 액터가 로드될 때 에셋 내부의 무거운 메쉬, 이펙트까지 메모리에 한 번에 다 끌고 올라온다. 캐릭터 외형 프리셋, UI 아이콘 모음 등 크기가 작고 고정된 데이터 그룹에 적합하다.

 

이번 프로젝트는 리슨 서버를 활용한 멀티플레이를 고려하고 있기 때문에, 만약 DataAsset 구조로 스테이지 데이터들을 게임모드나 서브시스템에 상시 연결해 두면 플레이어는 Stage 1을 플레이하고 있을 때도 모든 Stage의 메쉬와 데이터까지 호스트 PC 메모리에 강제로 다 올려야 하는 문제가 발생하게 된다. 따라서, PrimaryDataAsset을 사용해서 UDeliverySubsystem이 스테이지가 시작할 때 해당 스테이지 ID에 맞는 에셋과 데이터만 명령할 수 있어 메모리를 아낄 수 있다.

 

(StageData.h)

#pragma once

#include "CoreMinimal.h"
#include "Engine/DataAsset.h"
#include "GameplayTagContainer.h"
#include "StageData.generated.h"

USTRUCT(BlueprintType)
struct FStageBoxSpawnInfo
{
	GENERATED_BODY()
	
	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Stage Config")
	FGameplayTag BoxTypeTag;
	
	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Stage Config", meta = (ClampMin = "1"))
	int32 SpawnCount = 1;
};

/** Primary Data Asset */

UCLASS(BlueprintType)
class PARCEL_KNIGHT_API UStageData : public UPrimaryDataAsset
{
	GENERATED_BODY()
	
public:
	
	/** 스테이지 제한 시간 (초 단위). 일단 300초로 했습니다. */
	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Stage Rules", meta = (ClampMin = "10.0"))
	float TimeLimit = 300.0f;
	
	/** 스테이지 클리어 점수. 일단 15점으로 해볼게요. */
	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Stage Rules", meta = (ClampMin = "1"))
	int32 TargetScore = 15;
	
	/** 스테이지에서 자동 스폰 될 택배 상자 리스트 */
	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Stage Rules")
	TArray<FStageBoxSpawnInfo> BoxDataList;
	
public:
	/** 고유 런타임 식별 ID를 발급해주는 오버라이드 함수 */
	virtual FPrimaryAssetId GetPrimaryAssetId() const override
	{
		return FPrimaryAssetId("StageData", GetFName());
	}
};

이렇게 작성한 후에, 프로젝트 세팅에 PrimaryDataAsset을 등록해두어야 비동기(Async) 로딩 시스템이 가동된다.

빌드 후 에디터에서 프로젝트 세팅의 Game 카테고리에 있는 Asset Manager를 찾는다. 상세 창에서 Primary Asset Types to Scan 이라는 배열 항목을 찾아 요소를 추가하고, 새로 추가된 인덱스 내부 항목을 세팅한다.

 

이제 UDeliverySubsystem -> InitializeStage(StageData)에 이 에셋을 주입하여 완벽하게 연동되는 것을 확인할 수 있다.

 


*개인 프로젝트 및 개인 공부*

<진행상황>

 

1. 기획서 작성 및 검토

 

2. 유저 여정 작성(Part 1. 플레이어 Game Start)

더보기

PIE 버튼 클릭 -> MainMenuLevel 로드 -> Start 버튼 클릭 -> UDTLevelManagerSubsystem이 레벨 이동 요청 접수 -> 화면 1초 페이드 아웃 -> Location.NachoVillage 태그에 바인딩된 실제 레벨 오픈 -> 화면 1초 페이드 인 -> 조작권 활성화

 

3. 데이터 에셋 기반의 레벨 지도 정의 (DTLevelDataAsset 데이터 테이블)

 

4. 전역 레벨 매니저 서브시스템(DTLevelManagerSubsystem)

 

5. 네이티브 게임 플레이 태그 정의 (DTGameplayTags.h / cpp)

 

6. 레벨 데이터 에셋 클래스 구현 (DTLevelDataAsset.h / cpp)

 

 

 

 


*오늘의 총평*

팀프로젝트를 집중해서 진행했다. 코드를 작성하는 기본기도 중요하지만, 전반적인 구조를 이해하고 어떤 식으로 게임 로직이 작성되어있는지, 그리고 해당 기능을 구현하기 위해서 필요한 건 무엇인지 설명하기 위해서 다른 팀원의 코드도 읽어보면서 전반적인 구조를 이해하고자 했다.

'TIL' 카테고리의 다른 글

06.30 TIL  (0) 2026.06.30
06.29 TIL  (0) 2026.06.29
06.25 TIL  (0) 2026.06.25
06.24 TIL  (0) 2026.06.24
06.23 TIL  (0) 2026.06.23