Ch6 팀 프로젝트(3) - 쓰레기/쓰레기 분류기 구현

yys·2026년 8월 6일

TIL

목록 보기
80/86

이번 목표


이전 글에서는 물리 시뮬레이션으로 움직이는 쓰레기통을 만들었다. 통은 만들었지만, 정작 통에 담을 쓰레기그 쓰레기를 최종적으로 처리할 곳이 없었다.

이번에는 두 가지를 만들었다.

  • 쓰레기(AMD_TrashItem): 기지 바닥에 굴러다니는 캔, 작업복, 폐전지 같은 물건
  • 분류기(AMD_TrashSorter): 모선에 설치된 분리수거함. 물건을 넣으면 점수를 매기고 삼켜버린다

전체적인 파이프라인은 다음과 같다.

쓰레기 종류 DT화


쓰레기 종류는 프로토 타입 기준에서 10가지다.

종류의 경우, 현재까지는 쓰레기가 특별한 능력을 지닌다거나 하지 않으므로, 데이터화만 하기 위해 클래스는 딱 하나만 만들고, 종류별로 다른 값은 전부 표(DataTable) 로 뺐다.

행 이름이름환급 가산보너스무게(kg)
DrinkCanMetal음료캔0-0.4
ScrapToolMetal녹슨 폐공구3Tool3.0
TornWorkSuitGeneral찢어진 작업복2Workwear1.5
BrokenLabGlassNonBurnable깨진 실험 유리2Glass1.2
SpentBatteryContaminated폐전지4Battery0.8

표의 한 줄이 곧 쓰레기 한 종류이므로, 이 한 줄의 모양을 C++ 구조체로 정의했다.

USTRUCT(BlueprintType)
struct MARINEDEMOLITION_API FMD_TrashKindRow : public FTableRowBase
{
	GENERATED_BODY()

	/** 모선에서 이 쓰레기를 넣어야 하는 칸 */
	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "MD|Trash",
		meta = (Categories = "Score.Sorting"))
	FGameplayTag Category;

	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "MD|Trash")
	FText DisplayName;

	/** 운반 무게감 */
	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "MD|Trash",
		meta = (ClampMin = "0.01", Units = "kg"))
	float MassKg = 1.0f;

	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "MD|Trash")
	TSoftObjectPtr<UStaticMesh> Mesh;
};

ID 식별


그래서 쓰레기 액터가 스스로 들고 있는 정보는 종류 이름 하나뿐이다.

/** 종류 테이블의 행 이름. 에디터에서는 테이블 행 목록이 드롭다운으로 표시 */
UPROPERTY(ReplicatedUsing = OnRep_TrashKindId, EditAnywhere, BlueprintReadOnly,
	Category = "MD|Trash", meta = (GetOptions = "GetTrashKindIdOptions"))
FName TrashKindId = TEXT("DrinkCan");

meta = (GetOptions = ...)은 에디터 디테일 패널이 아래 함수를 알아서 호출해서 드롭다운 목록을 만들어준다.

TArray<FName> AMD_TrashItem::GetTrashKindIdOptions() const
{
	return UMD_TrashLibrary::GetTrashKindIds();
}

여기서 UMD_TrashLibrary::GetTrashKindIds()는 DeveloperSettings에 할당한 테이블의 아이디를 가져오는 함수로 만들어주었다.

레벨 디자이너는 텍스트로 "DrinkCan"이라고 직접 타이핑할 필요가 없다. 오타로 "Drinkcan"이라고 적어서 아무 일도 안 일어나는 사고를 원천 차단한다.
(이미지 아래 나머지 두 속성은 테스트 때문에 추가한 파라미터이다.)

ID에 따른 Value 값 가져오기


종류를 정하면 무게, 메시, 색을 표에서 읽어와 적용한다.

void AMD_TrashItem::ApplyTrashKindRow()
{
	if (!IsValid(Mesh)) return;

	FMD_TrashKindRow KindRow;
    // ID에 따른 모든 값을 가져오는 커스텀 함수
	UMD_TrashLibrary::FindTrashKindRow(TrashKindId, KindRow);

	// 메시의 질량을 오버라이딩 하는 함수
	Mesh->SetMassOverrideInKg(NAME_None, FMath::Max(KindRow.MassKg, 0.01f), true);

	// 행에 메시가 없으면 Blueprint에 설정된 메시를 유지
	if (UStaticMesh* KindMesh = KindRow.Mesh.LoadSynchronous())
	{
		Mesh->SetStaticMesh(KindMesh);
	}
	// ...
}

무게를 언리얼의 자동 계산에 맡기지 않고 직접 지정한 이유가 있는데, 언리얼은 메시의 부피로 무게를 추정하는데, 나중에 아티스트가 캔 모델을 조금 더 크게 만들면 아무도 모르는 사이에 캔이 무거워진다.
일단 손으로 들었을 때의 느낌은 기획 값이지 모델링 값이 아니라서, 표에 적은 숫자를 강제로 덮어썼다.

아직까진 메시를 전달받지 못해서 Tint로만 대체했다.

표를 찾는 창구를 하나로 만들기


앞서 언급했듯이, 테이블의 경우 DeveloperSettings를 통해 참조하도록 만들어주었는데 이 구조를 알아보자면 다음과 같다.

UCLASS(config = Game, defaultconfig, meta = (DisplayName = "MD Trash"))
class MARINEDEMOLITION_API UMD_TrashSettings : public UDeveloperSettings
{
	GENERATED_BODY()

public:
	/** 컨테이너 5종 수치. 행 구조체는 FMD_TrashCategoryRow */
	UPROPERTY(config, EditAnywhere, BlueprintReadOnly, Category = "Trash")
	TSoftObjectPtr<UDataTable> TrashCategoryTable;

	/** 산재 쓰레기 종류. 행 구조체는 FMD_TrashKindRow, 행 이름이 종류 ID */
	UPROPERTY(config, EditAnywhere, BlueprintReadOnly, Category = "Trash")
	TSoftObjectPtr<UDataTable> TrashKindTable;
};

UDeveloperSettings를 상속하면 Project Settings → Game → MD Trash 메뉴가 자동으로 생긴다.

그리고 표를 읽는 함수는 전부 UMD_TrashLibrary 한 곳에 모았다. 다른 코드는 표가 어디 있는지도, 몇 개의 표가 있는지도 모르며, 그냥 해당 종류가 몇 번 칸인지 물어보기만 하도록 책임 분리를 해주었다.

FGameplayTag UMD_TrashLibrary::GetTrashKindCategory(const FName TrashKindId)
{
	const FMD_TrashKindRow* Row = FindTrashKindRowInternal(TrashKindId);
	return Row ? Row->Category : FGameplayTag();
}

쓰레기 인터페이스


분류기에 들어갈 수 있는 물건은 쓰레기 뿐만 아니라, 벽을 잘라서 나온 조각벽도 분리 대상이고, 앞으로 오염물이나 따개비도 추가될 예정이기 때문에 따로 인터페이스를 할당한 액터라면 분류기가 받아들일 수 있도록 세팅했다.

class MARINEDEMOLITION_API IMD_TrashSortable
{
	GENERATED_BODY()

public:
	/** 자신의 분류 태그를 반환하는 함수*/
	UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "MD|Trash")
	FGameplayTag GetTrashCategory() const;

	/** 정분류 시 얹을 품목 보너스 태그를 반환하는 함수*/
	UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "MD|Trash")
	FGameplayTag GetSortScoreBonusTag() const;
};

대답하는 방법은 각자 알아서 정하고, 분류기는 결과만 받도록 만들어주었다.

분류기 만들기


분류기의 구조는 외형을 표시할 메시와 쓰레기를 투입할 Volume만 존재한다.

쓰레기통은 들고 다녀야 해서 물리를 켰지만, 분류기는 모선에 고정된 설비이기 때문에, 물리를 켜두면 플레이어가 부딪혔을 때 분리수거함이 밀려나거나 넘어진다.

IntakeVolume은 이전 글의 ContentsVolume과 같은 설정인데, QueryOnly + 모든 채널 무시 + PhysicsBody만 오버랩. 물건을 막지 않고 통과시키면서 감지만 한다.

분류기 종류 별 처리


분리수거 칸은 프로토 타입 상 총 5종이다. (금속, 일반, 플라스틱, 오염, 안타는 쓰레기)

여기서도 쓰레기 때 처리한 것과 유사하게 액터를 5대 배치하고 각자 담당 태그만 지정하게 했다.

분류기 행동 정의


물건이 들어오면 이 함수가 실행된다.

void AMD_TrashSorter::HandleBeginOverlap(/* ... */)
{
	if (!HasAuthority() || !IsValid(OtherActor)) return;

	// 첫 컴포넌트로 인해 삭제되었다면 중지
	if (OtherActor->IsActorBeingDestroyed()) return;

	// 분리 대상이 아닌 것은 통과
	if (!OtherActor->Implements<UMD_TrashSortable>()) return;

	// 분류 시작
	JudgeAndSwallow(OtherActor);
}

IsActorBeingDestroyed() 검사가 이번에 배운 부분이다.

오버랩 이벤트는 액터가 아니라 컴포넌트 단위로 발생한다. 한 액터가 충돌 컴포넌트를 여러 개 가지고 있으면 콜백이 연달아 여러 번 온다. 그런데 첫 번째 콜백에서 이미 Destroy()를 호출했다면, 두 번째 콜백은 이미 사라지는 중인 물건에 대해 점수를 한 번 더 준다.

Destroy()가 예약된 액터는 IsActorBeingDestroyed()true를 반환하므로, 이 한 줄로 중복 처리를 막도록 도와준다.

이제 실제 판정 로직은 다음과 같이 정의된다.

void AMD_TrashSorter::JudgeAndSwallow(AActor* Item)
{
	AMD_GameStateBase* MDGameState = GetWorld() ? GetWorld()->GetGameState<AMD_GameStateBase>() : nullptr;
	if (!IsValid(MDGameState)) return;

	// 오버랩된 아이템의 태그 정보를 얻고
	const FGameplayTag CorrectCategory = IMD_TrashSortable::Execute_GetTrashCategory(Item);

	if (!CorrectCategory.IsValid()) return;
	
    // 태그가 정확하게 분류기와 일치한지 확인
	const bool bCorrectSort = CorrectCategory.MatchesTagExact(AcceptedCategory);

물건에게 정답 칸을 물어보고, 내 담당 칸과 정확히 같은지 비교한다.

	// 만약 일치한다면
	if (bCorrectSort)
	{
		// GameState에 태그만 넣어주면 점수를 증가시켜주는 함수 수행
		MDGameState->AddScoreEvent(CorrectCategory);

		// 만약 보너스 태그까지 있다면
		const FGameplayTag BonusTag = IMD_TrashSortable::Execute_GetSortScoreBonusTag(Item);
		if (BonusTag.IsValid())
		{
        	// 보너스 태그도 점수 증가
			MDGameState->AddScoreEvent(BonusTag);
		}
	}
	else
	{
		만약 일치하지 않다면 미스 매칭 시 점수를 감소시키는 함수 수행
		MDGameState->AddScoreEvent(UMD_TrashLibrary::GetMisplacedSortScoreTag(CorrectCategory));
	}

정답이면 칸 점수를 주고, 특별한 품목이면 보너스를 얹는다. 칸 태그를 그대로 점수 이벤트 태그로 정의해주었다.

삼킴 후처리


마지막으로 물건을 없앤다.

	++SortedCount;

	const FVector SwallowLocation = Item->GetActorLocation();
	MulticastItemSwallowed(bCorrectSort, SwallowLocation);

	Item->Destroy();

이펙트나 소리는 모든 플레이어에게 보여야 하므로 NetMulticast로 보낸다.
그런데 파괴는 서버에서 일어나고, 클라이언트에 "저 액터 지워라"는 통지가 도착하는 시점과 멀티캐스트가 도착하는 시점은 다르다. 클라이언트가 멀티캐스트를 받았을 때 이미 액터가 사라져 있으면, "어디서 터뜨릴지"를 물어볼 대상이 없다.

그래서 위치를 값으로 복사해서 함께 실어 보낸다.

/** 액터는 서버에서 파괴되므로 위치를 실어 보냄*/
UFUNCTION(NetMulticast, Unreliable)
void MulticastItemSwallowed(bool bCorrectSort, FVector Location);

Unreliable로 둔 이유는, 이건 연출이기 때문이다. 네트워크가 잠깐 막혔을 때 이펙트 하나 빠지는 건 괜찮지만, 그것 때문에 진짜 중요한 패킷이 밀리면 안 되기 때문이다.

그리고 실제 연출은 C++이 아니라 Blueprint에 맡겨주는 방식으로 처리했다.

/** 삼킬 때 연출 훅 */
UFUNCTION(BlueprintImplementableEvent, Category = "MD|Trash")
void OnItemSwallowed(bool bCorrectSort, const FVector& Location);

아직 UI가 수정 중이라 행동 방식만 게시해보자면 다음과 같이 표현된다.

profile
게임 개발 지망생

0개의 댓글