개발 이야기 안하는 개발자

언리얼5_2_3 : 기믹 시스템의 제작 본문

Unreal/이득우의 언리얼 프로그래밍

언리얼5_2_3 : 기믹 시스템의 제작

07e 2024. 3. 28. 16:26
반응형

액터 컴포넌트를 활용한 스탯

액터에 부착할 수 있는 컴포넌트 중 트랜스폼이 없는 컴포넌트

액터의 기능을 확장할 때 컴포넌트로 분리해 모듈화를 할수 있음.

스탯 데이터를 담당하는 컴포넌트와 UI 위젯을 담당하는 컴포넌트로 분리

액터는 두 컴포넌트가 서로 통신하도록 중개하는 역할로 지정

 

언리얼 델리게이트를 활용한 발행구독 모델

푸시형태의 알림을 구현하는데 적합한 디자인 패턴

스탯이 변경되면 델리게이트에 연결된 컴포넌트에 알림을 보내 데이터를 개신

스탯 컴포넌트와 UI컴포넌트 사이의 느슨한 결합의 생성

발행구독과 옵저버의 차이는 발행구독 사이엔 델리게이트라는 연결다리가 있어서 스탯컴포넌트와 UI 위젯 컴포넌트가 서로 모른다는 장점이 있다.

 

캐릭터 머리 위에 HP bar를 제작한다.

Widget Blueprint를 UserWidget을 상속받아 제작한다.

 

Vertical Bar와 ProgressBar를 활용해서 위와같이 만든다.

알아보기 쉽게 하기 위해서 ProgressBar의 Percent를 0.5로 두고 중간까지만 채워 넣는다.

 

이번엔 UserWidget을 상속받아 클래스를 하나 만든다. (HpBarWidget)

해당 클래스에서는 이제 데이터를 입력받아 업데이트하는 기능을 제작할 것이다.

 

만약 위에서 만든 블루프린트 Widget의 상속을 변경하고 싶다면 블루프린트에서 부모클래스를 변경해주면 된다.

그래프를 누르고 ClassSetting으로 들어가 Parent Class를 수정해 주면 된다.

만약에 스크립트를 통해서 UI를 제작하게 될 경우 .Build.cs 파일에서 public dependency 모듈에 UMG를 추가해주어야 한다.

해당 클래스를 정의하기 전에 스탯에 대한 클래스부터 제작한다.

 

ActorComponent를 상속받아 CharacterStatComponent를 제작한다.

DECLARE_MULTICAST_DELEGATE(FOnHpZeroDelegate);
DECLARE_MULTICAST_DELEGATE_OneParam(FOnHpChangedDelegate, float /*CurrentHp*/);

class ARENABATTLE_API UABCharacterStatComponent : public UActorComponent
{
	GENERATED_BODY()

public:	
	// Sets default values for this component's properties
	UABCharacterStatComponent();

protected:
	// Called when the game starts
	virtual void BeginPlay() override;

public:
	FOnHpZeroDelegate OnHpZero;
	FOnHpChangedDelegate OnHpChanged;

	FORCEINLINE float GetMaxHp() { return MaxHp; }
	FORCEINLINE float GetCurrentHp() { return CurrentHp; }
	float ApplyDamage(float InDamage);

protected:
	void SetHp(float NewHp);

	UPROPERTY(VisibleInstanceOnly, Category = Stat)
	float MaxHp;

	UPROPERTY(Transient, VisibleInstanceOnly, Category = Stat)
	float CurrentHp;
		
};

해당 컴포넌트가 이제 스탯을 관리하게 될 것이다.

여기서 OnHpZero가 되었을때, OnHpChange상태일때 델리게이트를 호출해 주도록 제작한 것이다.

CurrentHp에서 Transient의 뜻은 불필요한 데이터를 관리하기 위해서 사용된 키워드 이다.

모든 변수는 디스크에 저장되는데, Max와 Current가 동일한 값일 경우 불필요하게 디스크를 소모하게 될 것이다.

이를 위해서 Transient를 쓰면 불필요한 공간 낭비를 안하게 해준다.

 

UABCharacterStatComponent::UABCharacterStatComponent()
{
	MaxHp = 200.0f;
	CurrentHp = MaxHp;
}


// Called when the game starts
void UABCharacterStatComponent::BeginPlay()
{
	Super::BeginPlay();

	SetHp(MaxHp);	
}

float UABCharacterStatComponent::ApplyDamage(float InDamage)
{
	const float PrevHp = CurrentHp;
	const float ActualDamage = FMath::Clamp<float>(InDamage, 0, InDamage);

	SetHp(PrevHp - ActualDamage);
	if (CurrentHp <= KINDA_SMALL_NUMBER)
	{
		OnHpZero.Broadcast();
	}

	return ActualDamage;
}

void UABCharacterStatComponent::SetHp(float NewHp)
{
	CurrentHp = FMath::Clamp<float>(NewHp, 0.0f, MaxHp);
	
	OnHpChanged.Broadcast(CurrentHp);
}

데미지가 들어오면 값을 연산하고 만약 체력이 0 인경우 델리게이트를 호출한다.

setup은 변경된 상태를 브로드케스트로 모두에게 알린다.

 

Actor LifeCycle

액터의 라이프 사이클이다.

아래쪽에 PostinitializeComponents 을 보면 왼쪽 찐한 파란색은 액터의 로딩이고

오른쪽의 하늘색은 액터의 스폰이다.

결국 모두 PostinitailizeComponents가 호출된다고 보면 된다.

마지막 BeginPlay가 호출되고 이후엔 Tick 호출이다.

 

즉, PostinitalizeComponents는 보유한 모든 컴포넌트가 로딩이 끝난 다음 호출되는 것이다.

액터를 마무리하려고 할땐 항상 PostinitalizeComponents에서 사용하면되고,

액터를 시작하려고 하면 BeginPlay를 사용하면된다. 이때, BeginPlay는 틱이 호출된다.

 

위젯 컴포넌트

위젯 컴포넌트는 컨테이너 역할만 하고 액터 위에 UI 위젯과는 독립적으로 동작한다.

발행 구독 모델의 구현을 위해 위젯 컴포넌트의 초기화 단계를 파악해야 한다.

위젯이 스탯 컴포넌트의 존재를 알아야만 발행구독을 사용할 수 있다.

따라서 UserWidget에서 델리게이트에 이벤트를 등록하려면 Actor의 존재를 알아야 하기 때문에 WidgetComponent로 부터 Owner의 존재를 가져와야 한다.

 

위젯 컴포넌트를 하나 제작한다. (WidgetComponent를 상속받으면 된다)

void UABWidgetComponent::InitWidget()
{
	Super::InitWidget();

	UABUserWidget* ABUserWidget = Cast<UABUserWidget>(GetWidget());
	if (ABUserWidget)
	{
		ABUserWidget->SetOwningActor(GetOwner());
	}
}

상속받아 virtual void InitWidget을 override 해서 구현한다.

WidgetComponent는 Widget을 가져와서 해당 widget이 알수 있도록 Owner가 누구인지 셋팅해 주어야 한다.

Super의 InitWidget에서 CreateInstance가 있기 때문에 이후에 GetWidget으로 가져올 수 있다.

 

User Widget도 하나 제작한다 (ABUserWidget)

이 유저 위젯은 방금 위에서 WidgetComponent가 준 Actor의 정보를 담고 있는 기능만 가지고 있다.

	UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Actor")
	TObjectPtr<AActor> OwningActor;

 

바로 위에서 만든 ABUserWIdget을 상속받은 클래스를 정의한다.

class ARENABATTLE_API UABHpBarWidget : public UABUserWidget
{
	GENERATED_BODY()
	
public:
	UABHpBarWidget(const FObjectInitializer& ObjectInitializer);

protected:
	virtual void NativeConstruct() override;

public:
	FORCEINLINE void SetMaxHp(float NewMaxHp) { MaxHp = NewMaxHp; }
	void UpdateHpBar(float NewCurrentHp);

protected:
	UPROPERTY()
	TObjectPtr<class UProgressBar> HpProgressBar;

	UPROPERTY()
	float MaxHp;
};

위젯이 사용할 클래스이다. 방금 위에서 만든 UABUserWidget을 상속받았기 때문에 WidgetComponent가 초기화 하면서 UserWidget에게 사용자 Actor 정보를 받울 수 있게된다.

UserWidget의 생성자는 다른 클래스와 다르게 FObjectInitializer라는 단일 객체를 받는다.

따라서 생성자도 저렇게 정의해야 한다.

 

IABCharacterWidgetInterface는 캐릭터 Base와 Widget을 묶는대에 사용된다.

인터페이스
class ARENABATTLE_API IABCharacterWidgetInterface
{
	GENERATED_BODY()

	// Add interface functions to this class. This is the class that will be inherited to implement this interface.
public:
	virtual void SetupCharacterWidget(class UABUserWidget* InUserWidget) = 0;

위 IABCharacterWidgetInterface는 매인캐릭터가 상속받아 구현되어있다.

void AABCharacterBase::SetupCharacterWidget(UABUserWidget* InUserWidget)
{
	UABHpBarWidget* HpBarWidget = Cast<UABHpBarWidget>(InUserWidget);
	if (HpBarWidget)
	{
		HpBarWidget->SetMaxHp(Stat->GetMaxHp());
		HpBarWidget->UpdateHpBar(Stat->GetCurrentHp());
		Stat->OnHpChanged.AddUObject(HpBarWidget, &UABHpBarWidget::UpdateHpBar);
	}
}

메인 캐릭터는 UserWidget을 받으면 이벤트를 델리게이트에 등록한다.

여기 써있는 Stat은 방금 위에서 만들었던 ActorComponent이다.

 

UABHpBarWidget::UABHpBarWidget(const FObjectInitializer& ObjectInitializer) : Super(ObjectInitializer)
{
	MaxHp = -1.0f;
}

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

	HpProgressBar = Cast<UProgressBar>(GetWidgetFromName(TEXT("PbHpBar")));
	ensure(HpProgressBar);

	IABCharacterWidgetInterface* CharacterWidget = Cast<IABCharacterWidgetInterface>(OwningActor);
	if (CharacterWidget)
	{
		CharacterWidget->SetupCharacterWidget(this);
	}
}

void UABHpBarWidget::UpdateHpBar(float NewCurrentHp)
{
	ensure(MaxHp > 0.0f);
	if (HpProgressBar)
	{
		HpProgressBar->SetPercent(NewCurrentHp / MaxHp);
	}
}

NatvieConstruct는 거의 대부분의 모든 UI 초기화가 끝난 상태라고 보면 된다.

즉, UI가 초기화가 끝난 직후 호출되는 메소드이다.

부모클래스가 UserWidget에게 Actor를 받도록 기능이 구현되어 있다.

가져온 Actor가 곧 Owner이고, Owner에게 자신의 정보(Widget)을 넘겨줄수 있게 된다.

 

이제 CharacterBase의 기능을 제작한다.

CharacterBase는 위에서 만든 Interface를 상속받고, 그렇기 때문에 SEtupCharacterWidget을 추가 정해야 한다.

CharacterBase.h

...
// Stat Section
protected:
	UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = Stat, Meta = (AllowPrivateAccess = "true"))
	TObjectPtr<class UABCharacterStatComponent> Stat;

// UI Widget Section
protected:
	UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = Widget, Meta = (AllowPrivateAccess = "true"))
	TObjectPtr<class UWidgetComponent> HpBar;

	virtual void SetupCharacterWidget(class UABUserWidget* InUserWidget) override;
...

 

CharacaterBase.cpp

생성자
...
	// Stat Component 
	Stat = CreateDefaultSubobject<UABCharacterStatComponent>(TEXT("Stat"));

	// Widget Component 
	HpBar = CreateDefaultSubobject<UABWidgetComponent>(TEXT("Widget"));
	HpBar->SetupAttachment(GetMesh());
	HpBar->SetRelativeLocation(FVector(0.0f, 0.0f, 180.0f));
	static ConstructorHelpers::FClassFinder<UUserWidget> HpBarWidgetRef(TEXT("/Game/ArenaBattle/UI/WBP_HpBar.WBP_HpBar_C"));
	if (HpBarWidgetRef.Class)
	{
		HpBar->SetWidgetClass(HpBarWidgetRef.Class);
		HpBar->SetWidgetSpace(EWidgetSpace::Screen);
		HpBar->SetDrawSize(FVector2D(150.0f, 15.0f)); //위젯의 실제 크기
		HpBar->SetCollisionEnabled(ECollisionEnabled::NoCollision);
	}
    
void AABCharacterBase::PostInitializeComponents()
{
	Super::PostInitializeComponents();

	Stat->OnHpZero.AddUObject(this, &AABCharacterBase::SetDead);
}

void AABCharacterBase::SetupCharacterWidget(UABUserWidget* InUserWidget)
{
	UABHpBarWidget* HpBarWidget = Cast<UABHpBarWidget>(InUserWidget);
	if (HpBarWidget)
	{
		HpBarWidget->SetMaxHp(Stat->GetMaxHp());
		HpBarWidget->UpdateHpBar(Stat->GetCurrentHp());
		Stat->OnHpChanged.AddUObject(HpBarWidget, &UABHpBarWidget::UpdateHpBar);
	}
}
...

생성이 모두 끝났을때 PostInitializeCOmponent 메소드에서 이벤트를 호출해 준다.

체력이 모두 다 떨어졌을 경우 SetDead메소드가 호출될 것이다.

이때는 컴포터는가 끝났을때 기다렸기 때문에 stat의 정보가 있다.

값이 변경될때 마다 이를 인지하고 UpdateHpBar가 움직이도록 로직을 구현했다.

 

 

아이템 시스템 제작

 

상자에 닿으면 아이템을 얻고 상자는 없어지며 효과가 있는 충돌 체크를 포함한 기능 구현

 

현재 프로젝트의 레이어다.

데이터 레이어 : 게임을 구성하는 기본 데이터(스탯, 캐릭터 레벨 테이블 등)

미들웨어 레이어 : 게임에 사용되는 미들 웨어 모듈 (UI , 아이템, 애니메이션, AI 등)

게임 레이어 : 게임 로직을 구체적으로 구현하는데 사용 (캐릭터 , 게임모드 등등)

 

각 상자에 적용될 아이템에 대해 데이터를 미리 작성해 놓아야 한다.

PrimitiveDataAsset은 언리얼에서 제공하는 데이터 폼 클래스 이다.

이렇게 하면 데이터를 에셋으로 관리할 수 있게 해준다.

 

우선 부모 클래스인 ABItemData를 제작한다.

UENUM(BlueprintType)
enum class EItemType : uint8
{
	Weapon = 0,
	Potion,
	Scroll
};

/**
 * 
 */
UCLASS()
class ARENABATTLE_API UABItemData : public UPrimaryDataAsset
{
	GENERATED_BODY()
	
public:
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = Type)
	EItemType Type;
};

타입은 총 3종류를 만들 것이다.

 

이를 상속받은 무기 클래스를 제작해 본다.

UCLASS()
class ARENABATTLE_API UABWeaponItemData : public UABItemData
{
	GENERATED_BODY()
	
public:
	UPROPERTY(EditAnywhere, Category = Weapon)
	TSoftObjectPtr<USkeletalMesh> WeaponMesh;
};

해당 데이터 에셋은 메쉬 데이터를 가지고 있다.

사용한 SoftObjectPtr은 바로 로딩하지 않고 필요할때 (호출하고 사용하게 될때) 로딩하는 방식이다.

ObjectPtr은 하드 레퍼런싱으로 액터를 로딩할때 같이 메모리에 로딩이 된다.

만약 이러한 하드 레퍼런싱이 많아지면 다 로딩하면 낭비이기 때문에 필요할때 로딩하도록 에셋 주소 문자열만 로딩하도록 하는 방식이 바로 SoftObjectPtr이다.

필요시에 에셋을 로딩함으로 에셋 로딩시간이 소요된다.

 

ItemData 클래스를 통해서 PrimitiveAssetData를 제작한다.

무기만 WeaponItemData를 통해 블루프린트를 제작한다.

 

캐릭터가 아이템을 습득할때 사용할 인터페이스를 제작한다.

class ARENABATTLE_API IABCharacterItemInterface
{
	GENERATED_BODY()

	// Add interface functions to this class. This is the class that will be inherited to implement this interface.
public:
	virtual void TakeItem(class UABItemData* InItemData) = 0;
};

 

메인캐릭터가 이를 상속받도록 로직을 수정한다

CharacterBase.h

DECLARE_DELEGATE_OneParam(FOnTakeItemDelegate, class UABItemData* /*InItemData*/);
USTRUCT(BlueprintType)
struct FTakeItemDelegateWrapper
{
	GENERATED_BODY()
	FTakeItemDelegateWrapper() {}
	FTakeItemDelegateWrapper(const FOnTakeItemDelegate& InItemDelegate) : ItemDelegate(InItemDelegate) {}	
	FOnTakeItemDelegate ItemDelegate;
};

...

// Item Section
protected:
	UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = Equipment, Meta = (AllowPrivateAccess = "true"))
	TObjectPtr<class USkeletalMeshComponent> Weapon;

	UPROPERTY()
	TArray<FTakeItemDelegateWrapper> TakeItemActions;

	virtual void TakeItem(class UABItemData* InItemData) override;
	virtual void DrinkPotion(class UABItemData* InItemData);
	virtual void EquipWeapon(class UABItemData* InItemData);
	virtual void ReadScroll(class UABItemData* InItemData);

 

상속받은 매인 캐릭터는 메소드를 정의한다.

다수의 아이템에 맞는 이벤트를 사용할 예정이라서 구조체로 만들어서 이를 배열에 묶어서 사용한다.

이렇게 하면 관리하기 편해진다.

	// Item Actions
	TakeItemActions.Add(FTakeItemDelegateWrapper(FOnTakeItemDelegate::CreateUObject(this, &AABCharacterBase::EquipWeapon)));
	TakeItemActions.Add(FTakeItemDelegateWrapper(FOnTakeItemDelegate::CreateUObject(this, &AABCharacterBase::DrinkPotion)));
	TakeItemActions.Add(FTakeItemDelegateWrapper(FOnTakeItemDelegate::CreateUObject(this, &AABCharacterBase::ReadScroll)));

	// Weapon Component
	Weapon = CreateDefaultSubobject<USkeletalMeshComponent>(TEXT("Weapon"));
	Weapon->SetupAttachment(GetMesh(), TEXT("hand_rSocket"));

생성자에서 각 이벤트를 추가해주는데, 이때 바로 CreateUObject를 통해 바로 생성해서 추가한다.

무기는 매쉬에 있는 소켓위치에 붙도록 하는 로직이다.

해당 소켓은 이미 해당 에셋에 포함되어 있는 상태로 바로 가져와서 사용했다.

소켓의 위치를 바꾸면 위 무기에 붙는 위치도 변경될 것이다.

 

void AABCharacterBase::TakeItem(UABItemData* InItemData)
{
	if (InItemData)
	{
		TakeItemActions[(uint8)InItemData->Type].ItemDelegate.ExecuteIfBound(InItemData);
	}
}

void AABCharacterBase::DrinkPotion(UABItemData* InItemData)
{
	UE_LOG(LogABCharacter, Log, TEXT("Drink Potion"));
}

void AABCharacterBase::EquipWeapon(UABItemData* InItemData)
{
	UABWeaponItemData* WeaponItemData = Cast<UABWeaponItemData>(InItemData);
	if (WeaponItemData)
	{
		if (WeaponItemData->WeaponMesh.IsPending())
		{
			WeaponItemData->WeaponMesh.LoadSynchronous();
		}
		Weapon->SetSkeletalMesh(WeaponItemData->WeaponMesh.Get());
	}
}

void AABCharacterBase::ReadScroll(UABItemData* InItemData)
{
	UE_LOG(LogABCharacter, Log, TEXT("Read Scroll"));
}

타입을 int형식으로 바꿔서 enum값의 번째로 이벤트를 가져오는 방식이다.

호출받은 아이템의 타입을 바로 가져와서 각 타입에 맞는 이벤트를 호출하게 한다.

ExecuteIfBound는 Execute와 비슷한데, 바인드가 된 함수가 있는지부터 확인하고 있으면 실행하는 메소드이다.

IsPending()은 아직 로딩이 안된상태를 뜻하는데, SoftObjectPtr로 아직 로딩이 안되었기 때문에 Pending상태일때 비동기로 LoadSynchronous를 통해 비동기 로드를 진행한다.

 

 

Actor를 상속받는 ItemBox 제작

class ARENABATTLE_API AABItemBox : public AActor
{
	GENERATED_BODY()
	
public:	
	// Sets default values for this actor's properties
	AABItemBox();

protected:
	UPROPERTY(VisibleAnywhere, Category = Box)
	TObjectPtr<class UBoxComponent> Trigger;

	UPROPERTY(VisibleAnywhere, Category = Box)
	TObjectPtr<class UStaticMeshComponent> Mesh;

	UPROPERTY(VisibleAnywhere, Category = Effect)
	TObjectPtr<class UParticleSystemComponent> Effect;

	UPROPERTY(EditAnywhere, Category = Item)
	TObjectPtr<class UABItemData> Item;

	UFUNCTION()
	void OnOverlapBegin(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepHitResult);

	UFUNCTION()
	void OnEffectFinished(class UParticleSystemComponent* ParticleSystem);
};

충돌 체크를 위한 트리거, 상자 모양 매쉬, 닿을때 나오는 이펙트 총 3개 사용한다.

아이템에 적용되는 데이터를 가져와서 적용한다.

 

AABItemBox::AABItemBox()
{
	Trigger = CreateDefaultSubobject<UBoxComponent>(TEXT("TriggerBox"));
	Mesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("Mesh"));
	Effect = CreateDefaultSubobject<UParticleSystemComponent>(TEXT("Effect"));

	RootComponent = Trigger;
	Mesh->SetupAttachment(Trigger);
	Effect->SetupAttachment(Trigger);

	Trigger->SetCollisionProfileName(CPROFILE_ABTRIGGER);
	Trigger->SetBoxExtent(FVector(40.0f, 42.0f, 30.0f));
	Trigger->OnComponentBeginOverlap.AddDynamic(this, &AABItemBox::OnOverlapBegin);

	static ConstructorHelpers::FObjectFinder<UStaticMesh> BoxMeshRef(TEXT("/Script/Engine.StaticMesh'/Game/ArenaBattle/Environment/Props/SM_Env_Breakables_Box1.SM_Env_Breakables_Box1'"));
	if (BoxMeshRef.Object)
	{
		Mesh->SetStaticMesh(BoxMeshRef.Object);
	}
	Mesh->SetRelativeLocation(FVector(0.0f, -3.5f, -30.0f));
	Mesh->SetCollisionProfileName(TEXT("NoCollision"));

	static ConstructorHelpers::FObjectFinder<UParticleSystem> EffectRef(TEXT("/Script/Engine.ParticleSystem'/Game/ArenaBattle/Effect/P_TreasureChest_Open_Mesh.P_TreasureChest_Open_Mesh'"));
	if (EffectRef.Object)
	{
		Effect->SetTemplate(EffectRef.Object);
		Effect->bAutoActivate = false;
	}
}

사용할 트리거, 매쉬, 파티클 제작한다.

 

void AABItemBox::OnOverlapBegin(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepHitResult)
{
	if (nullptr == Item)
	{
		Destroy();
		return;
	}

	IABCharacterItemInterface* OverlappingPawn = Cast<IABCharacterItemInterface>(OtherActor);
	if (OverlappingPawn)
	{
		OverlappingPawn->TakeItem(Item);
	}

	Effect->Activate(true);
	Mesh->SetHiddenInGame(true);
	SetActorEnableCollision(false);
	Effect->OnSystemFinished.AddDynamic(this, &AABItemBox::OnEffectFinished);
}

void AABItemBox::OnEffectFinished(UParticleSystemComponent* ParticleSystem)
{
	Destroy();
}

충돌 체크를 진행해서 충돌 대상자가 ItemInterface를 포함한 오브젝트인지 확인한다.

매인 캐릭터가 인터페이스에 맞는 아이템을 사용하도록 로직을 추가한다.

파티클이 끝날때 호출되는 델리게이트인 OnSystemFinished에 종료 메소드를 등록해 놓는다.

 

 

상자를 먹으면 파티클과 함께 무기가 손에 쥐어진다.

이때 무기를 보면 로드중이라서 하얀색인것을 볼 수 있다.

 

 

 

무한맵 제작

문을 넘어가면 다음 룸이 계속 생기는 무한맵을 제작한다.

입장 -> 대전 -> 보상 -> 다음 스테이지 -> 입장 ....

 

에셋 매니저

언리얼 엔진이 제공하는 애셋 관리 싱글톤 클래스이다.

엔진이 초기화될때 제공되며 에셋 정보를 요청해 받을 수 있다.

PrimaryAssetId를 사용해서 프로젝트 내 에셋의 주소를 얻어올 수 있고, Tag와 Name 두가지 키 조합으로 구성되어 있다.

 

Actor를 상속받는 StageGimmick을 제작한다.

이 기믹은 플레이어가 문을 통과하는지를 체크하게 되는 역할이다.

class ARENABATTLE_API AABStageGimmick : public AActor
{
	GENERATED_BODY()
	
public:	
	// Sets default values for this actor's properties
	AABStageGimmick();

protected:
	virtual void OnConstruction(const FTransform& Transform) override;

// Stage Section
protected:
	UPROPERTY(VisibleAnywhere, Category = Stage, Meta = (AllowPrivateAccess = "true"))
	TObjectPtr<class UStaticMeshComponent> Stage;

	UPROPERTY(VisibleAnywhere, Category = Stage, Meta = (AllowPrivateAccess = "true"))
	TObjectPtr<class UBoxComponent> StageTrigger;

	UFUNCTION()
	void OnStageTriggerBeginOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult);

스테이지를 정의하는 부분이다.

Stage가 있고, 트리거에 닿을 경우 호출되는 이벤트를 정의한다.

...생성자

	// Stage Section
	Stage = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("Stage"));
	RootComponent = Stage;

	static ConstructorHelpers::FObjectFinder<UStaticMesh> StageMeshRef(TEXT("/Script/Engine.StaticMesh'/Game/ArenaBattle/Environment/Stages/SM_SQUARE.SM_SQUARE'"));
	if (StageMeshRef.Object)
	{
		Stage->SetStaticMesh(StageMeshRef.Object);
	}

	StageTrigger = CreateDefaultSubobject<UBoxComponent>(TEXT("StageTrigger"));
	StageTrigger->SetBoxExtent(FVector(775.0, 775.0f, 300.0f));
	StageTrigger->SetupAttachment(Stage);
	StageTrigger->SetRelativeLocation(FVector(0.0f, 0.0f, 250.0f));
	StageTrigger->SetCollisionProfileName(CPROFILE_ABTRIGGER);
	StageTrigger->OnComponentBeginOverlap.AddDynamic(this, &AABStageGimmick::OnStageTriggerBeginOverlap);

무대를 가져오고 트리거를 스테이지에 맞게 제작한다.

 

// Gate Section
protected:
	UPROPERTY(VisibleAnywhere, Category = Gate, Meta = (AllowPrivateAccess = "true"))
	TMap<FName, TObjectPtr<class UStaticMeshComponent>> Gates;

	UPROPERTY(VisibleAnywhere, Category = Gate, Meta = (AllowPrivateAccess = "true"))
	TArray<TObjectPtr<class UBoxComponent>> GateTriggers;

	UFUNCTION()
	void OnGateTriggerBeginOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult);

	void OpenAllGates();
	void CloseAllGates();

문을 정의한다.

문도 동일하게 트리거를 만들고 열고 닫고를 정의한다.

...생성자
	// Gate Section
	static FName GateSockets[] = { TEXT("+XGate"), TEXT("-XGate"), TEXT("+YGate"), TEXT("-YGate") };
	static ConstructorHelpers::FObjectFinder<UStaticMesh> GateMeshRef(TEXT("/Script/Engine.StaticMesh'/Game/ArenaBattle/Environment/Props/SM_GATE.SM_GATE'"));
	for (FName GateSocket : GateSockets)
	{
		UStaticMeshComponent* Gate = CreateDefaultSubobject<UStaticMeshComponent>(GateSocket);
		Gate->SetStaticMesh(GateMeshRef.Object);
		Gate->SetupAttachment(Stage, GateSocket);
		Gate->SetRelativeLocation(FVector(0.0f, -80.5f, 0.0f));
		Gate->SetRelativeRotation(FRotator(0.0f, -90.0f, 0.0f));
		Gates.Add(GateSocket, Gate);

		FName TriggerName = *GateSocket.ToString().Append(TEXT("Trigger"));
		UBoxComponent* GateTrigger = CreateDefaultSubobject<UBoxComponent>(TriggerName);
		GateTrigger->SetBoxExtent(FVector(100.0f, 100.0f, 300.0f));
		GateTrigger->SetupAttachment(Stage, GateSocket);
		GateTrigger->SetRelativeLocation(FVector(70.0f, 0.0f, 250.0f));
		GateTrigger->SetCollisionProfileName(CPROFILE_ABTRIGGER);
		GateTrigger->OnComponentBeginOverlap.AddDynamic(this, &AABStageGimmick::OnGateTriggerBeginOverlap);
		GateTrigger->ComponentTags.Add(GateSocket);

		GateTriggers.Add(GateTrigger);
	}
    
    
    ...
    
    
void AABStageGimmick::OpenAllGates()
{
	for (auto Gate : Gates)
	{
		(Gate.Value)->SetRelativeRotation(FRotator(0.0f, -90.0f, 0.0f));
	}
}

void AABStageGimmick::CloseAllGates()
{
	for (auto Gate : Gates)
	{
		(Gate.Value)->SetRelativeRotation(FRotator::ZeroRotator);
	}
}

동서남북방향으로 문을 4개 제작한다.

이때 태그가 여러개 등록되어 있다면 에러를 띄운다(Check())

 

각 소켓에 붙여서 문을제작한다.

각 문이 열리고 닫히도록 메소드를 제작한다.

제작한 메쉬에 소켓이 있는것을 볼 수 있다.

 

스테이지 상태를 관리하는 로직을 구현한다.

DECLARE_DELEGATE(FOnStageChangedDelegate);
USTRUCT(BlueprintType)
struct FStageChangedDelegateWrapper
{
	GENERATED_BODY()
	FStageChangedDelegateWrapper() { }
	FStageChangedDelegateWrapper(const FOnStageChangedDelegate& InDelegate) : StageDelegate(InDelegate) {}
	FOnStageChangedDelegate StageDelegate;
};

UENUM(BlueprintType)
enum class EStageState : uint8
{
	READY = 0,
	FIGHT,
	REWARD,
	NEXT
};

...


// State Section
protected:
	UPROPERTY(EditAnywhere, Category = Stage, Meta = (AllowPrivateAccess = "true"))
	EStageState CurrentState;

	void SetState(EStageState InNewState);

	UPROPERTY()
	TMap<EStageState, FStageChangedDelegateWrapper> StateChangeActions;

	void SetReady();
	void SetFight();
	void SetChooseReward();
	void SetChooseNext();

각 상태에 맞춰서 제작한다.

상태가 많아 관리하기 힘들기 때문에 Wrapper를 구현해서 관리하도록 한다.

 

.cpp 생성자

	// State Section
	CurrentState = EStageState::READY;
	StateChangeActions.Add(EStageState::READY, FStageChangedDelegateWrapper(FOnStageChangedDelegate::CreateUObject(this, &AABStageGimmick::SetReady)));
	StateChangeActions.Add(EStageState::FIGHT, FStageChangedDelegateWrapper(FOnStageChangedDelegate::CreateUObject(this, &AABStageGimmick::SetFight)));
	StateChangeActions.Add(EStageState::REWARD, FStageChangedDelegateWrapper(FOnStageChangedDelegate::CreateUObject(this, &AABStageGimmick::SetChooseReward)));
	StateChangeActions.Add(EStageState::NEXT, FStageChangedDelegateWrapper(FOnStageChangedDelegate::CreateUObject(this, &AABStageGimmick::SetChooseNext)));


...


void AABStageGimmick::SetState(EStageState InNewState)
{
	CurrentState = InNewState;

	if (StateChangeActions.Contains(InNewState))
	{
		StateChangeActions[CurrentState].StageDelegate.ExecuteIfBound();
	}
}

void AABStageGimmick::SetReady()
{
	StageTrigger->SetCollisionProfileName(CPROFILE_ABTRIGGER);
	for (auto GateTrigger : GateTriggers)
	{
		GateTrigger->SetCollisionProfileName(TEXT("NoCollision"));
	}

	OpenAllGates();
}

void AABStageGimmick::SetFight()
{
	StageTrigger->SetCollisionProfileName(TEXT("NoCollision"));
	for (auto GateTrigger : GateTriggers)
	{
		GateTrigger->SetCollisionProfileName(TEXT("NoCollision"));
	}

	CloseAllGates();

	GetWorld()->GetTimerManager().SetTimer(OpponentTimerHandle, this, &AABStageGimmick::OnOpponentSpawn, OpponentSpawnTime, false);
}

void AABStageGimmick::SetChooseReward()
{
	StageTrigger->SetCollisionProfileName(TEXT("NoCollision"));
	for (auto GateTrigger : GateTriggers)
	{
		GateTrigger->SetCollisionProfileName(TEXT("NoCollision"));
	}

	CloseAllGates();
	SpawnRewardBoxes();
}

void AABStageGimmick::SetChooseNext()
{
	StageTrigger->SetCollisionProfileName(TEXT("NoCollision"));
	for (auto GateTrigger : GateTriggers)
	{
		GateTrigger->SetCollisionProfileName(CPROFILE_ABTRIGGER);
	}

	OpenAllGates();
}

생성자에서 Wrapper를 만들어서 다음 상태로 넘어갈 수 있도록 이벤트를 제작하고 콜리션의 이름을 변경한다.

보상 상자를 모두 먹기 전까진 이동못하게 된다.

 

OnConstruction()

에디터레벨에서 값이 변경되는 것을 감지해서 호출하는 메소드이다.

만약 CurrentState가 변경되면 상태에 맞는 이벤트를 호출하기 위해서 사용한다.

.h

protected:
	virtual void OnConstruction(const FTransform& Transform) override;

...

.cpp
void AABStageGimmick::OnConstruction(const FTransform& Transform)
{
	Super::OnConstruction(Transform);

	SetState(CurrentState);
}

이렇게 함으로써 값이 바뀌는것을 감지할 때마다 SetState를 호출하게 된다.

이는 런타임이 아닌 에디터에서도 볼 수 있다.

 

전투 부분을 구현한다.

// Fight Section
protected:
	UPROPERTY(EditAnywhere, Category = Fight, Meta = (AllowPrivateAccess = "true"))
	TSubclassOf<class AABCharacterNonPlayer> OpponentClass;

	UPROPERTY(EditAnywhere, Category = Fight, Meta = (AllowPrivateAccess = "true"))
	float OpponentSpawnTime;	//스폰 딜레이

	UFUNCTION()
	void OnOpponentDestroyed(AActor* DestroyedActor);

	FTimerHandle OpponentTimerHandle;
	void OnOpponentSpawn();

TSubClassOf는 에디터에서 AABCharacterNonPlayer를 상속받은 대상만 보여주도록 하는 기능이다.

앞으로 구현할 NPC들은 NonPlayer를 상속받기만 한다면 SubClassOf로 제작하기 편리할 것이다.

	// Fight Section
	OpponentSpawnTime = 2.0f;
	OpponentClass = AABCharacterNonPlayer::StaticClass();

...

void AABStageGimmick::OnOpponentDestroyed(AActor* DestroyedActor)
{
	SetState(EStageState::REWARD);
}

void AABStageGimmick::OnOpponentSpawn()
{
	const FVector SpawnLocation = GetActorLocation() + FVector::UpVector * 88.0f;
	AActor* OpponentActor = GetWorld()->SpawnActor(OpponentClass, &SpawnLocation, &FRotator::ZeroRotator);
	AABCharacterNonPlayer* ABOpponentCharacter = Cast<AABCharacterNonPlayer>(OpponentActor);
	if (ABOpponentCharacter)
	{
		ABOpponentCharacter->OnDestroyed.AddDynamic(this, &AABStageGimmick::OnOpponentDestroyed);
	}
}

적군의 위치를 배정하고 적군이 죽을때 상태값을 변경하도록 이벤트를 등록한다.

 

이제 보상에 대한 로직을 구현한다.

// Reward Section
protected:
	UPROPERTY(VisibleAnywhere, Category = Reward, Meta = (AllowPrivateAccess = "true"))
	TSubclassOf<class AABItemBox> RewardBoxClass;

	UPROPERTY(VisibleAnywhere, Category = Reward, Meta = (AllowPrivateAccess = "true"))
	TArray<TWeakObjectPtr<class AABItemBox>> RewardBoxes;

	TMap<FName, FVector> RewardBoxLocations;

	UFUNCTION()
	void OnRewardTriggerBeginOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult);

	void SpawnRewardBoxes();

SubClassOf는 위에 설명처럼 아이템박스로 구현된 클래스들만 보여준다.

WeakObjectPtr은 약참조로 RewardBoxes를 외부 액터에서 사용할때 사용한다.

예를 들어 BoxActor가 해당 스크립트 외에서 삭제가 되거나 아니면 파괴가 될때 언리얼에서 해당 BoxActor를 메모리에서 해제하지 않을 수 있다. 강참조(ObjectPtr)를 걸게 되면 해당 Stage도 Box를, 다른 액터도 Box를 가르키게 되면 한쪽이 파괴해도 다른 한쪽은 아직 사용중으로 판단해서 언리얼에서 메모리 해제를 하지 않기 때문이다.

WeakObjectPtr은 다른 쪽에서 강참조를 걸고 삭제을때 WeakObjectPtr을 한쪽에서도 같이 따라서 파괴가 되는것을 인정하고 언리얼 에디터에서 이를 메모리 해제하게 된다.

따라서, 한 액터가 다른 액터를 사용할땐 WeakObjectPtr을 걸어서 최적화를 대비하는것이 가장 좋다.

 

.cpp 생성자

	// Reward Section
	RewardBoxClass = AABItemBox::StaticClass();
	for (FName GateSocket : GateSockets)
	{
		FVector BoxLocation = Stage->GetSocketLocation(GateSocket) / 2;
		RewardBoxLocations.Add(GateSocket, BoxLocation);
	}
    
    ....
    
void AABStageGimmick::OnRewardTriggerBeginOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
	for (const auto& RewardBox : RewardBoxes)
	{
		if (RewardBox.IsValid())
		{
			AABItemBox* ValidItemBox = RewardBox.Get();
			AActor* OverlappedBox = OverlappedComponent->GetOwner();
			if (OverlappedBox != ValidItemBox)
			{
				ValidItemBox->Destroy();
			}
		}
	}

	SetState(EStageState::NEXT);
}

void AABStageGimmick::SpawnRewardBoxes()
{
	for (const auto& RewardBoxLocation : RewardBoxLocations)
	{
		FVector WorldSpawnLocation = GetActorLocation() + RewardBoxLocation.Value + FVector(0.0f, 0.0f, 30.0f);
		AActor* ItemActor = GetWorld()->SpawnActor(RewardBoxClass, &WorldSpawnLocation, &FRotator::ZeroRotator);
		AABItemBox* RewardBoxActor = Cast<AABItemBox>(ItemActor);
		if (RewardBoxActor)
		{
			RewardBoxActor->Tags.Add(RewardBoxLocation.Key);
			RewardBoxActor->GetTrigger()->OnComponentBeginOverlap.AddDynamic(this, &AABStageGimmick::OnRewardTriggerBeginOverlap);
			RewardBoxes.Add(RewardBoxActor);
		}
	}
}

보상 아이템을 배치하고 이벤트를 등록한다

선택받지 못한 모든 상자를 삭제한다.

 

여기서 AABItemBox가 Trigger를 숨겨놨기 때문에 Get 메소드를 하나 만들어 줘야 한다.

	FORCEINLINE class UBoxComponent* GetTrigger() { return Trigger; }

 

맵 가운데를 방금 만든 스테이지를 블루프린터로 교체한다.

그리고 상태값을 Next로 변경해둔다.

 

에셋 매니저로 아이템을 로드해본다.

이렇게 프로젝트 셋팅엔 AssetManager가 있고 각 에셋들을 관리해준다. 이는 폴더를 지정해 줌으로써 해당 파일 안을 모두 관리해주게 한다.

 

ItemData 헤더에 AssetId를 가져오는 부모 클래스를 상속받아 정의한다.

(웨폰에도 똑같은 코드를 한번더 작성해야 한다)

	FPrimaryAssetId GetPrimaryAssetId() const override
	{
		return FPrimaryAssetId("ABItemData", GetFName());
	}

모두 ABItemData를 아이디로 지정해 두어서 모두 같은 아이템 에셋이라는 것을 보여준다.

 

이를 상자에서 호출하도록 구현한다.

ItemBox.cpp
void AABItemBox::PostInitializeComponents()
{
	Super::PostInitializeComponents();

	UAssetManager& Manager = UAssetManager::Get();

	TArray<FPrimaryAssetId> Assets;
	Manager.GetPrimaryAssetIdList(TEXT("ABItemData"), Assets);
	ensure(0 < Assets.Num());

	int32 RandomIndex = FMath::RandRange(0, Assets.Num() - 1);
	FSoftObjectPtr AssetPtr(Manager.GetPrimaryAssetPath(Assets[RandomIndex]));
	if (AssetPtr.IsPending())
	{
		AssetPtr.LoadSynchronous();
	}
	Item = Cast<UABItemData>(AssetPtr.Get());
	ensure(Item);
}

데이터를 리스트로 모두 가져온다.

가져온 대상들을 랜덤하게 배치한다.

 

AssetManager를 정의했다면 에셋들을 한번 리로드해주는게 좋다.

 

 

이렇게 해서 문을 넘어가기 직전 맵이 생기고, 넘어가면 문이 닫히고, 몹이 생기며 잡으면 보상을 주는 무한맵을 제작했다.

 

반응형