Unreal5 컴포지션

이승한·2024년 3월 19일
0

Unreal5

목록 보기
6/11

Composition

언리얼 C++만의 컴포지션 기법을 사용해 복잡한 언리얼 오브젝트를 효과적으로 생성하기

학습

  • 언리얼 C++의 컴포지션 기법을 사용해 오브젝트의 포함 관계를 설계하는 방법 학습
  • 언리얼 C++이 제공하는 확장 열거형 타입의 선언과 활용 방법의 학습

객체 지향 프로그래밍의 설계는 크게 2가지로 나눌 수 있다.
1. 상속 : 성질이 같은 부모 클래스와 자식 클래스의 관계를 뜻하는 is-A 관계
2. 컴포지션 : 성질이 다른 두 객체에서 어떤 객체가 다른 객체를 소유하는 Has-A 관계


좋은 객체지향 설계 제작하기 위한 모던 설계 기법 SOLID

  • Single Responsibility Principle (단일 책임 원칙)
    -> 하나의 객체는 하나의 의무만 가지도록 설계한다.
  • Open-Closed Principle (개방 폐쇄 원칙)
    -> 기존에 구현된 코드를 변경하지 않으면서 새로운 기능을 추가할 수 있도록 설계한다.
  • Liskov Substitution Principle (리스코프 치환 원칙)
    -> 자식 객체를 부모 객체로 변경해도 작동에 문제 없을 정도로 상속을 단순히 사용한다.
  • Interface Segregation Design (인터페이스 분리 원칙)
    -> 객체가 구현해야 할 기능이 많다면 이들을 여러 개의 단순한 인터페이스들로 분리해 설계한다.
  • Dependency Injection Principle (의존성 역전 원칙)
    -> 구현된 실물보다 구축해야 할 추상적 개념에 의존한다.

모던 객체 설계 기법의 설계 핵심은 상속을 단순화하고, 단순한 기능을 가진 다수의 객체를 조합해 복잡한 객체를 구성

이를 활용해서

컴포지션 설계 예시

  • 학교 구성원 시스템의 설계 예시
    -> 학교 구성원을 위해 출입증을 만들기
    -> 출입증은 Person에서 구현해서 상속 ? , 컴포지션으로 분리 ?

  • Person에서 직접 구현해서 상속시키는 경우의 문제
    -> 새로운 형태의 구성원이 등장하면 Person을 수정
    -> 상위 클래스 Person을 수정하면, 하위 클래스들의 동작은 문제 없음을 보장할수 있나?

  • 따라서 설계적으로 출입증은 컴포지션으로 분리

  • 그렇다면 컴포지션으로 포함시키면 모든 것이 해결될 수 있는가?

효과적인 설계를 위해 프로그래밍 언어가 제공하는 고급 기법을 활용


언리얼 엔진에서의 컴포지션 구현 방법

* 하나의 언리얼 오브젝트에는 항상 클래스 기본 오브젝트 CDO 존재
-> CDO 수정시 엔진 종료
* 언리얼 오브젝트에 다른 언리얼 오브젝트를 조합할 때 다음의 선택지 존재
방법 1: CDO에 미리 언리얼 오브젝트를 생성해서 조합한다. (필수적 포함)
-> CreateDefaultSubobject() API 사용
방법 2: CDO에 빈 포인터만 넣고 런타임에서 언리얼 오브젝트를 생성해 조합한다. (선택적 포함)
-> NewObject() API 사용

이 두가지 방법은 CDO는 생성자 코드 이고, 2번째 방법에서 마지막 조합하는 단계는 게임 콘텐츠를 제작할 때 동작하는 런타임 코드가 된다.

  • 언리얼 오브젝트를 생성할 떄 컴포지션 정보를 구축할 수 있다.
    -> 내가 소유한 언리얼 오브젝트를 SubObject라고 한다.
    -> 나를 소유한 언리얼 오브젝트를 Outer라고 한다.

예제를 위한 클래스

Person 상속 <---------- 컴포지션 Card
1. Student
2. Staff
3. Teacher

Card
헤더 파일
enum class를 정의할 때는 앞에 E를 붙여서 구분하면 좋다. ex) ECardType
UENUM() 을 선언해주면, UMETA와 같은 다른 정보를 붙여 줄 수있다.

#pragma once

#include "CoreMinimal.h"
#include "UObject/NoExportTypes.h"
#include "Card.generated.h"

UENUM()
enum class ECardType : uint8
{
	Student = 1 UMETA(DisplayName = "For Student"),
	Teacher UMETA(DisplayName = "For Teacher"),
	Staff UMETA(DisplayName = "For Staff"),
	Invalid
};

/**
 * 
 */
UCLASS()
class UNREALCOMPOSITION_API UCard : public UObject
{
	GENERATED_BODY()
	
public:
	UCard();

	ECardType GetCardType() const { return CardType; }
	void SetCardType(ECardType InCardType) { CardType = InCardType; }
private:
	UPROPERTY()
	ECardType CardType;

	UPROPERTY()
	uint32 Id;
	
};

CPP

#include "Card.h"

UCard::UCard()
{
	CardType = ECardType::Invalid;
	Id = 0;
}

Person
헤더
1. Card를 전방선언을 통해 헤더를 포함하지않고 하는게 좋다.
보통 오브젝트는 포인터로 관리하기 때문에 정확한 구현부는 알 수 없지만 포인터 크기를 가지기 떄문에 전방 선언을 통해서 의존성을 최대한 없앨 수 있다.

전방선언 부분
UPROPERTY()
class UCard* Card;

근데,위의 방법은 언리얼엔진 4버전까지는 정석이였지만

언리얼엔진5부터
TObjectPtr 사용 -> 이 자체가 포인터이기 때문에 포인터 연산자를 빼고 선언

#pragma once

#include "CoreMinimal.h"
#include "UObject/NoExportTypes.h"
#include "Person.generated.h"

/**
 * 
 */
UCLASS()
class UNREALCOMPOSITION_API UPerson : public UObject
{
	GENERATED_BODY()
	
public:
	UPerson();

	FORCEINLINE const FString& GetName() const { return Name; }
	FORCEINLINE void SetName(const FString& InName) { Name = InName; }

	FORCEINLINE class UCard* GetCard() const { return Card; }
	FORCEINLINE void SetCard(class UCard* InCard) { Card = InCard; }

protected:
	UPROPERTY()
	FString Name;

	UPROPERTY()
	TObjectPtr<class UCard> Card;
};

주의점) Getter 함수로 선언한 FORCEINLINE FString& GetName() const { return Name; } 으로 선언한 부분은 오류가 발생한다.
why? const로 선언했지만 FString& 이 참조타입이기 때문에 바뀔 수 있다는 말이라 오류가 발생하는데 여기서 const FString&으로 바꿔주면 오류가 사라진다.

CPP 여기서는 첫번째 방법으로 구현 CreateDefaultSubobject API 사용

#include "Person.h"
#include "Card.h"

UPerson::UPerson()
{
	Name = TEXT("홍길동");
    //CreateDefaultSubobject<UCard>(인자값으로 반드시 FName이라는 식별자를 넣어줘야 한다. 고유한 이름이면 된다.)
    //그래서 사람들이 FNAME 이면 NAME 접두사를 많이 쓴다.
	Card = CreateDefaultSubobject<UCard>(TEXT("NAME_Card"));
}

이 후, Teacher , Student, Staff 클래스에 똑같이 구현하면 되는데, 이는 부모 클래스의 생성자가 호출된 후에 실행되기에 CreateDefaultSubobject를 사용하지않아도 된다. 사용하면 오히려 중복이 된다. 이미 생성이 되어있기때문에 단순히
Card클래스의 SetCardType 함수를 호출해서 사용

Teacher.cpp

#include "Teacher.h"
#include "Card.h"
UTeacher::UTeacher()
{
	Name = TEXT("이선생");
	Card->SetCardType(ECardType::Teacher);
}

void UTeacher::DoLesson()
{
	ILessonInterface::DoLesson();
	UE_LOG(LogTemp, Log, TEXT("%s님은 가르칩니다."), *Name);
}

Teacher.h

#pragma once

#include "CoreMinimal.h"
#include "Person.h"
#include "LessonInterface.h"
#include "Teacher.generated.h"

/**
 * 
 */
UCLASS()
class UNREALCOMPOSITION_API UTeacher : public UPerson,public ILessonInterface
{
	GENERATED_BODY()

public:
	UTeacher();
	
	virtual void DoLesson() override;
};

Student.cpp


#include "Student.h"
#include "Card.h"
UStudent::UStudent()
{
	Name = TEXT("이학생");
	Card->SetCardType(ECardType::Student);
}

void UStudent::DoLesson()
{
	ILessonInterface::DoLesson();
	UE_LOG(LogTemp, Log, TEXT("%s님은 공부합니다."), *Name);
}

Student.h

#pragma once

#include "CoreMinimal.h"
#include "Person.h"
#include "LessonInterface.h"
#include "Student.generated.h"

/**
 * 
 */
UCLASS()
class UNREALCOMPOSITION_API UStudent : public UPerson,public ILessonInterface
{
	GENERATED_BODY()
	
public:
	UStudent();

	virtual void DoLesson() override;
};

Staff.cpp

#include "Staff.h"
#include "Card.h"
UStaff::UStaff()
{
	Name = TEXT("이직원");
	Card->SetCardType(ECardType::Staff);
}

Staff.h

#pragma once

#include "CoreMinimal.h"
#include "Person.h"
#include "Staff.generated.h"

/**
 * 
 */
UCLASS()
class UNREALCOMPOSITION_API UStaff : public UPerson
{
	GENERATED_BODY()

public:
	UStaff();
	
};

MyGameInstance.h

#pragma once

#include "CoreMinimal.h"
#include "Engine/GameInstance.h"
#include "MyGameInstance.generated.h"

/**
 * 
 */
UCLASS()
class UNREALCOMPOSITION_API UMyGameInstance : public UGameInstance
{

	GENERATED_BODY()
	
public:
	UMyGameInstance();

	virtual void Init() override;

private:
	UPROPERTY()
	FString SchoolName;
	
};

MyGameInstance.cpp


#include "MyGameInstance.h"
#include "Student.h"
#include "Teacher.h"
#include "Staff.h"
#include "Card.h"

UMyGameInstance::UMyGameInstance()
{
	SchoolName = TEXT("기본학교");
}

void UMyGameInstance::Init()
{
	Super::Init();


	UE_LOG(LogTemp, Log, TEXT("========================"));
	
	TArray<UPerson*> Persons = { NewObject<UStudent>(),NewObject<UTeacher>(),NewObject<UStaff>() };
	for (const auto Person : Persons)
	{
		const UCard* OwnCard = Person->GetCard();
		check(OwnCard);
		ECardType CardType = OwnCard->GetCardType();
		//UE_LOG(LogTemp, Log, TEXT("%s님이 소유한 카드 종류 %d"), *Person->GetName(), CardType);

		const UEnum* CardEnumType= FindObject<UEnum>(nullptr, TEXT("/Script/UnrealComposition.ECardType"));
		if (CardEnumType)
		{
			FString CardMetaData = CardEnumType->GetDisplayNameTextByValue((int64)CardType).ToString();

			UE_LOG(LogTemp, Log, TEXT("%s님이 소유한 카드 종류 %s"), *Person->GetName(), *CardMetaData);
		}
		
	}
}

참고

const UCard* OwnCard = Person->GetCard();
check(OwnCard);
ECardType CardType = OwnCard->GetCardType();
UE_LOG(LogTemp, Log, TEXT("%s님이 소유한 카드 종류 %d"), *Person->GetName(), CardType);

결과 :
LogTemp: ========================
LogTemp: 이학생님이 소유한 카드 종류 1
LogTemp: 이선생님이 소유한 카드 종류 2
LogTemp: 이직원님이 소유한 카드 종류 3

우리가 CardType을 uint8로 했기에 1,2,3 값이 나오는 것을 알 수 있다.

추가로, UMETA(DisplayName)로 지정했던 값을 가져오는 부분

const UEnum* CardEnumType= FindObject<UEnum>(nullptr, TEXT("/Script/UnrealComposition.ECardType"));
if (CardEnumType)
{
	FString CardMetaData = CardEnumType->GetDisplayNameTextByValue((int64)CardType).ToString();

	UE_LOG(LogTemp, Log, TEXT("%s님이 소유한 카드 종류 %s"), *Person->GetName(), *CardMetaData);
}

FindObject API를 사용하여 UEum 열거형 타입을 가져온 후
첫번째 인자로 nullptr , 두번째 인자로 TEXT로 절대 주소값을 이용하여 원하는 타입정보를 얻어올 수 있다.
C++에 생성된 언리얼 객체들은 Script라는 절대 주소를 가진다. 그 후 우리가 선언한 프로젝트 이름이 모듈이름을 써주고 (지금은 UnrealComposition) 마지막으로 타입 이름인 ECardType을 써준다.

얻어왔으면 GetDisplayNameTextByValue 함수를 통해 인자로 int64만 받기때문에 CardType 형변환으로 써주고 ToString으로 변환
그러면 FText로 반환 되는데 이는 다국어 지원 문자열이라서 출력할때는 FString으로 얻어올 수있다.

결과:
LogTemp: ========================
LogTemp: 이학생님이 소유한 카드 종류 For Student
LogTemp: 이선생님이 소유한 카드 종류 For Teacher
LogTemp: 이직원님이 소유한 카드 종류 For Staff

0개의 댓글