Modular Gameplay Actor in Lyra

Jangmanbo·2025년 3월 24일

GameplayTag

  • 계층화된 enum
  • 에디터에서는 ProjectSetting > GameplayTags에서 추가 가능
  • 그러나 에디터로 추가하면 C++ 코드에서 작업하기 어렵다. 따라서 Lyra에서는 코드로 GameplayTag를 추가하고 있다.

C++에서 GameplayTag 추가하기

struct FLyraGameplayTags
{
public:

	static const FLyraGameplayTags& Get() { return GameplayTags; }
	static void InitializeNativeTags();
	static FGameplayTag FindTagByString(FString TagString, bool bMatchPartialString = false);

public:
	// ...
	FGameplayTag InputTag_Move;
	FGameplayTag InputTag_Look_Mouse;
	FGameplayTag InputTag_Look_Stick;
	FGameplayTag InputTag_Crouch;
	FGameplayTag InputTag_AutoRun;
    
    // ...

	TMap<uint8, FGameplayTag> MovementModeTagMap;
	TMap<uint8, FGameplayTag> CustomMovementModeTagMap;

protected:

	void AddAllTags(UGameplayTagsManager& Manager);
	void AddTag(FGameplayTag& OutTag, const ANSICHAR* TagName, const ANSICHAR* TagComment);
	void AddMovementModeTag(FGameplayTag& OutTag, const ANSICHAR* TagName, uint8 MovementMode);
	void AddCustomMovementModeTag(FGameplayTag& OutTag, const ANSICHAR* TagName, uint8 CustomMovementMode);

private:

	static FLyraGameplayTags GameplayTags;
};

static 변수인 GameplayTags를 가지고 있다. 즉 Singletone으로 사용
헤더에서 선언한 FGameplayTag 변수를 에디터에서 추가할 때처럼 추가해보자

void FLyraGameplayTags::InitializeNativeTags()
{
	UGameplayTagsManager& Manager = UGameplayTagsManager::Get();
	GameplayTags.AddAllTags(Manager);
	// ...
}

void FLyraGameplayTags::AddAllTags(UGameplayTagsManager& Manager)
{
	// ...
	AddTag(InputTag_Move, "InputTag.Move", "Move input.");
	AddTag(InputTag_Look_Mouse, "InputTag.Look.Mouse", "Look (mouse) input.");
	AddTag(InputTag_Look_Stick, "InputTag.Look.Stick", "Look (stick) input.");
	AddTag(InputTag_Crouch, "InputTag.Crouch", "Crouch input.");
	AddTag(InputTag_AutoRun, "InputTag.AutoRun", "Auto-run input.");
	// ...
}

void FLyraGameplayTags::AddTag(FGameplayTag& OutTag, const ANSICHAR* TagName, const ANSICHAR* TagComment)
{
	OutTag = UGameplayTagsManager::Get().AddNativeGameplayTag(FName(TagName), FString(TEXT("(Native) ")) + FString(TagComment));
}

InitializeNativeTags함수에서 헤더에 선언한 게임플레이 태그들을 에디터에 추가한다.



AddTag(InputTag_Move, "InputTag.Move", "Move input.");

아까 GameplayTag는 계층화된 enum이라고 했다.
이 코드는 InputTag>Move 태그를 에디터에 추가하는 코드다.

InputTag_Move: 에디터에 추가한 게임플레이 태그를 C++에서 사용하기 위한 변수

void ULyraAssetManager::InitializeAbilitySystem()
{
	FLyraGameplayTags::InitializeNativeTags();
	// ...
}

그렇다면 FLyraGameplayTags::InitializeNativeTags는 언제 호출될까
ULyraAssetManager::InitializeAbilitySystem에서, 즉 에디터가 켜지기도 전에 호출된다.

GameInstance

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

	// Register our custom init states
	UGameFrameworkComponentManager* ComponentManager = GetSubsystem<UGameFrameworkComponentManager>(this);
	if (ensure(ComponentManager))
	{
		const FLyraGameplayTags& GameplayTags = FLyraGameplayTags::Get();

		ComponentManager->RegisterInitState(GameplayTags.InitState_Spawned, false, FGameplayTag());
		ComponentManager->RegisterInitState(GameplayTags.InitState_DataAvailable, false, GameplayTags.InitState_Spawned);
		ComponentManager->RegisterInitState(GameplayTags.InitState_DataInitialized, false, GameplayTags.InitState_DataAvailable);
		ComponentManager->RegisterInitState(GameplayTags.InitState_GameplayReady, false, GameplayTags.InitState_DataInitialized);
	}
}

UGameFrameworkComponentManager와 GameplayTag를 이용해 Component들의 초기화 순서를 정할 수 있다.

초기화 순서를 정하는 이유: B 컴포넌트는 A 컴포넌트의 초기화 이후에 초기화 가능한 경우들이 있다. (ex. A:스킬 B:이펙트)






Modular Gameplay Actor

Lyra에서는 AModularGameplayActor를 상속받는 ALyraCharacter를 사용하고 있다.

LyraPawnExtensionComponent

  • UPawnComponent를 상속, Pawn에 부착되는 컴포넌트
  • PawnData를 캐싱한다
    • ALyraGameMode::SpawnDefaultPawnAtTransform_Implementation 참고
  • Controller를 캐싱한다
    • 정확히는 Owner Actor의 Controller를 가지고 있음. APawn::PossessedBy에서 Controller 세팅
  • Pawn에 부착되는 다른 컴포넌트들의 초기화 순서 등을 관리
  • 따라서 다른 컴포넌트들보다 항상 먼저 생성됨이 보장되어야 한다

LyraPawnExtensionComponent를 가장 먼저 생성하는 방법

UCLASS(Config = Game, Meta = (ShortTooltip = "The base character pawn class used by this project."))
class LYRAGAME_API ALyraCharacter : public AModularCharacter, public IAbilitySystemInterface, public IGameplayCueInterface, public IGameplayTagAssetInterface, public ILyraTeamAgentInterface
{
	GENERATED_BODY()
	
    // ..
private:
	UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Lyra|Character", Meta = (AllowPrivateAccess = "true"))
	TObjectPtr<ULyraPawnExtensionComponent> PawnExtComponent;
    // ..
};


ALyraCharacter::ALyraCharacter(const FObjectInitializer& ObjectInitializer)
	: Super(ObjectInitializer.SetDefaultSubobjectClass<ULyraCharacterMovementComponent>(ACharacter::CharacterMovementComponentName))
{
	// Tick 비활성화하고 이벤트로 초기화해서 효율적
    // LyraHeroComponent,LyraPawnExtensionComponent도 마찬가지라는데 얘네는 생성자에 안보인다..
	PrimaryActorTick.bCanEverTick = false;
    
    PawnExtComponent = CreateDefaultSubobject<ULyraPawnExtensionComponent>(TEXT("PawnExtensionComponent"));
	PawnExtComponent->OnAbilitySystemInitialized_RegisterAndCall(FSimpleMulticastDelegate::FDelegate::CreateUObject(this, &ThisClass::OnAbilitySystemInitialized));
	PawnExtComponent->OnAbilitySystemUninitialized_Register(FSimpleMulticastDelegate::FDelegate::CreateUObject(this, &ThisClass::OnAbilitySystemUninitialized));
    // ..
}

이렇게 Native C++에서 LyraPawnExtensionComponent을 생성하고


에디터로 LyraHeroComponent를 추가하면
언리얼 엔진에서 에디터보다 Native C++을 먼저 실행하기 때문에 LyraPawnExtensionComponent가 먼저 생성됨을 보장할 수 있다.

UGameFrameworkComponentManager에 컴포넌트를 등록하는 과정

초기화 순서 등을 관리하기 위해서는 UGameFrameworkComponentManager가 필요하다.
따라서 Actor에 컴포넌트를 부착할 때 UGameFrameworkComponentManager에 해당 컴포넌트를 등록한다.
= UGameFrameworkComponentManager에는 어떤 인스턴스에 어떤 컴포넌트가 부착되어있는지 저장하게 된다.

// 컴포넌트를 부착할 때 호출되는 함수
// UActorComponent::OnRegister() 오버라이드
void ULyraPawnExtensionComponent::OnRegister()
{
	Super::OnRegister();

	// OnRegister에서 올바른 Actor에 해당 컴포넌트가 제대로 부착되었는지 확인
	const APawn* Pawn = GetPawn<APawn>();
	ensureAlwaysMsgf((Pawn != nullptr), TEXT("LyraPawnExtensionComponent on [%s] can only be added to Pawn actors."), *GetNameSafe(GetOwner()));

	TArray<UActorComponent*> PawnExtensionComponents;
	Pawn->GetComponents(ULyraPawnExtensionComponent::StaticClass(), PawnExtensionComponents);
	ensureAlwaysMsgf((PawnExtensionComponents.Num() == 1), TEXT("Only one LyraPawnExtensionComponent should exist on [%s]."), *GetNameSafe(GetOwner()));

	// 상속받은 IGameFrameworkInitStateInterface의 함수
	RegisterInitStateFeature();
}

void IGameFrameworkInitStateInterface::RegisterInitStateFeature()
{
	UObject* ThisObject = Cast<UObject>(this);
	AActor* MyActor = GetOwningActor();
	UGameFrameworkComponentManager* Manager = UGameFrameworkComponentManager::GetForActor(MyActor);
    
    // FeatureName: 컴포넌트의 이름
    // GetFeatureName: 각 컴포넌트에서 오버라이드해서 정의
	const FName MyFeatureName = GetFeatureName();

	if (MyActor && Manager)
	{
		Manager->RegisterFeatureImplementer(MyActor, MyFeatureName, ThisObject);
	}
}

bool UGameFrameworkComponentManager::RegisterFeatureImplementer(AActor* Actor, FName FeatureName, UObject* Implementer)
{
	if (Actor == nullptr || FeatureName.IsNone())
	{
		// TODO Ensure?
		return false;
	}

	// 해당 Actor 인스턴스의 ActorStruct 구조체를 가져온다
    // 같은 클래스여도 FObjectKey를 통해 다른 인스턴스임을 구분할 수 있다
	FActorFeatureData& ActorStruct = FindOrAddActorData(Actor);

	FActorFeatureState* FoundState = nullptr;
	for (FActorFeatureState& State : ActorStruct.RegisteredStates)	// RegisteredStates: 해당 Actor의 컴포넌트 정보 리스트
	{
		if (State.FeatureName == FeatureName)
		{
			FoundState = &State;
		}
	}

	if (!FoundState)
	{
		FoundState = &ActorStruct.RegisteredStates.Emplace_GetRef(FeatureName);
	}

	FoundState->Implementer = Implementer;	// Implementer: 컴포넌트
	return true;
}

UGameFrameworkComponentManager::FActorFeatureData& UGameFrameworkComponentManager::FindOrAddActorData(AActor* Actor)
{
	check(Actor);

	// FObjectKey: 동일한 CDO로부터 생성된 인스턴스들을 구분하기 위한 키
	FActorFeatureData& ActorStruct = ActorFeatureMap.FindOrAdd(FObjectKey(Actor));
	if (!ActorStruct.ActorClass.IsValid())
	{
		ActorStruct.ActorClass = Actor->GetClass();
	}
	return ActorStruct;
}

BeginPlay, EndPlay

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

	// BindOnActorInitStateChanged: 어떤 컴포넌트의 InitState변경에 대한 이벤트 등록 (ULyraGameInstance::Init 참고)
	// PawnExtensionComponent는 모든 컴포넌트들의 초기화 동작을 관리하므로
    // 모든 컴포넌트(NAME_None)의 모든 상태 변경(FGameplayTag())에 대한 이벤트를 받음
	BindOnActorInitStateChanged(NAME_None, FGameplayTag(), false);
	
	// 나의 InitState 변경 시도
	ensure(TryToChangeInitState(FLyraGameplayTags::Get().InitState_Spawned));
    
    // 나를 포함한 모든 컴포넌트의 InitState를 가능한 State까지 업데이트
	CheckDefaultInitialization();
}

// Actor가 파괴되는 시점에 호출
void ULyraPawnExtensionComponent::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
	// ..
    
    // OnRegister에서 등록했던 InitStateFeature를 반드시 제거
	UnregisterInitStateFeature();

	Super::EndPlay(EndPlayReason);
}

LyraHeroComponent

  • UPawnComponent를 상속, Pawn에 부착되는 컴포넌트
  • 카메라, 입력 등 플레이어가 제어하는 시스템의 초기화 처리
  • 캐릭터<>카메라, 입력 간의 종속성이 없어진다.
  • ex. 캐릭터의 카메라나 입력 방식을 바꾸고 싶다면 HeroComponent를 바꿔주기만 하면 된다.

BeginPlay 동작

나머지 함수들은 ULyraPawnExtensionComponent와 거의 동일해서 패스

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

	// 초기화를 담당하는 LyraPawnExtensionComponent의 State만 관찰
	BindOnActorInitStateChanged(ULyraPawnExtensionComponent::NAME_ActorFeatureName, FGameplayTag(), false);

	ensure(TryToChangeInitState(FLyraGameplayTags::Get().InitState_Spawned));
    
    // 나의 InitState를 가능한 State까지 업데이트
    // ULyraPawnExtensionComponent::CheckDefaultInitialization로도 호출된다
	CheckDefaultInitialization();
}

LyraPawnExtensionComponent와 그 외 컴포넌트의 State 변경 (=초기화)

Lyra InitState 과정
InitState_Spawned > InitState_DataAvailable > InitState_DataInitialized > InitState_GameplayReady

LyraPawnExtensionComponent

bool ULyraPawnExtensionComponent::CanChangeInitState(UGameFrameworkComponentManager* Manager, FGameplayTag CurrentState, FGameplayTag DesiredState) const
{
	check(Manager);

	APawn* Pawn = GetPawn<APawn>();
	const FLyraGameplayTags& InitTags = FLyraGameplayTags::Get();

	if (!CurrentState.IsValid() && DesiredState == InitTags.InitState_Spawned)
	{
		// As long as we are on a valid pawn, we count as spawned
		if (Pawn)
		{
			return true;
		}
	}
	if (CurrentState == InitTags.InitState_Spawned && DesiredState == InitTags.InitState_DataAvailable)
	{
		// ALyraGameMode::SpawnDefaultPawnAtTransform_Implementation 참고
		if (!PawnData)
		{
			return false;
		}

		const bool bHasAuthority = Pawn->HasAuthority();
		const bool bIsLocallyControlled = Pawn->IsLocallyControlled();

		if (bHasAuthority || bIsLocallyControlled)
		{
			// Owner Actor의 Controller가 있는지 확인
            // APawn::PossessedBy에서 Controller를 세팅하므로 그 이후에 InitState_DataAvailable로 변경 가능하다
            // APawn::PossessedBy가 완료된 이후(Controller가 세팅된 이후) 호출되는 ALyraCharacter::SetupPlayerInputComponent에서
            // ExtensionComponent의 CheckDefaultInitialization를 호출해서 State를 변경해준다.
			if (!GetController<AController>())
			{
				return false;
			}
		}

		return true;
	}
	else if (CurrentState == InitTags.InitState_DataAvailable && DesiredState == InitTags.InitState_DataInitialized)
	{
		// Transition to initialize if all features have their data available
		return Manager->HaveAllFeaturesReachedInitState(Pawn, InitTags.InitState_DataAvailable);
	}
	else if (CurrentState == InitTags.InitState_DataInitialized && DesiredState == InitTags.InitState_GameplayReady)
	{
		return true;
	}

	return false;
}

void ULyraPawnExtensionComponent::OnActorInitStateChanged(const FActorInitStateChangedParams& Params)
{
	// If another feature is now in DataAvailable, see if we should transition to DataInitialized
	if (Params.FeatureName != NAME_ActorFeatureName)
	{
		const FLyraGameplayTags& InitTags = FLyraGameplayTags::Get();
		if (Params.FeatureState == InitTags.InitState_DataAvailable)
		{
			CheckDefaultInitialization();
		}
	}
}

LyraHeroComponent

bool ULyraHeroComponent::CanChangeInitState(UGameFrameworkComponentManager* Manager, FGameplayTag CurrentState, FGameplayTag DesiredState) const
{
	check(Manager);

	const FLyraGameplayTags& InitTags = FLyraGameplayTags::Get();
	APawn* Pawn = GetPawn<APawn>();

	if (!CurrentState.IsValid() && DesiredState == InitTags.InitState_Spawned)
	{
		// As long as we have a real pawn, let us transition
		if (Pawn)
		{
			return true;
		}
	}
	else if (CurrentState == InitTags.InitState_Spawned && DesiredState == InitTags.InitState_DataAvailable)
	{
		// The player state is required.
		if (!GetPlayerState<ALyraPlayerState>())
		{
			return false;
		}

		// If we're authority or autonomous, we need to wait for a controller with registered ownership of the player state.
		if (Pawn->GetLocalRole() != ROLE_SimulatedProxy)
		{
			AController* Controller = GetController<AController>();

			const bool bHasControllerPairedWithPS = (Controller != nullptr) && \
				(Controller->PlayerState != nullptr) && \
				(Controller->PlayerState->GetOwner() == Controller);

			if (!bHasControllerPairedWithPS)
			{
				return false;
			}
		}

		const bool bIsLocallyControlled = Pawn->IsLocallyControlled();
		const bool bIsBot = Pawn->IsBotControlled();

		if (bIsLocallyControlled && !bIsBot)
		{
			ALyraPlayerController* LyraPC = GetController<ALyraPlayerController>();

			// The input component and local player is required when locally controlled.
			if (!Pawn->InputComponent || !LyraPC || !LyraPC->GetLocalPlayer())
			{
				return false;
			}
		}

		return true;
	}
	else if (CurrentState == InitTags.InitState_DataAvailable && DesiredState == InitTags.InitState_DataInitialized)
	{
		// Wait for player state and extension component
		ALyraPlayerState* LyraPS = GetPlayerState<ALyraPlayerState>();

		return LyraPS && Manager->HasFeatureReachedInitState(Pawn, ULyraPawnExtensionComponent::NAME_ActorFeatureName, InitTags.InitState_DataInitialized);
	}
	else if (CurrentState == InitTags.InitState_DataInitialized && DesiredState == InitTags.InitState_GameplayReady)
	{
		return true;
	}

	return false;
}

void ULyraHeroComponent::OnActorInitStateChanged(const FActorInitStateChangedParams& Params)
{
	if (Params.FeatureName == ULyraPawnExtensionComponent::NAME_ActorFeatureName)
	{
		if (Params.FeatureState == FLyraGameplayTags::Get().InitState_DataInitialized)
		{
			// If the extension component says all all other components are initialized, try to progress to next state
			CheckDefaultInitialization();
		}
	}
}

InitState_DataAvailable > InitState_DataInitialized 과정만 봤을 때 (다른 부분은 주석으로 설명..)

PawnExtensionComponent을 제외한 모든 컴포넌트가 InitState_DataAvailable
=> ULyraPawnExtensionComponent::OnActorInitStateChanged: PawnExtensionComponent가 InitState_DataInitialized로 상태 변경
=> LyraHeroComponent::OnActorInitStateChanged: 자신의 상태를 InitState_DataInitialized로 변경

이런 식으로 tick 없이 이벤트를 통해 ULyraPawnExtensionComponent와 그 외 컴포넌트들의 초기화 과정을 수행한다.

0개의 댓글