1일1알

Lyra분석 - LyraGameMode, experience 본문

언리얼/Lyra 프로젝트 분석

Lyra분석 - LyraGameMode, experience

영춘권의달인 2024. 7. 8. 17:04

개인적으로 Lyra 프로젝트를 분석한 것이고, 틀린 내용이 있을 수 있습니다.

 

오늘은 GameMode를 분석해 보려고 한다. 

Lyra에서는 ALyraGameMode클래스를 GameMode 클래스로 사용한다.

 

void ALyraGameMode::InitGame(const FString& MapName, const FString& Options, FString& ErrorMessage)
{
	Super::InitGame(MapName, Options, ErrorMessage);

	// Wait for the next frame to give time to initialize startup settings
	GetWorld()->GetTimerManager().SetTimerForNextTick(this, &ThisClass::HandleMatchAssignmentIfNotExpectingOne);
}

처음에 호출되는 InitGame함수에서는 다음 틱에 HandleMatchAssignmentIfNotExpectingOne라는 함수가 호출되도록 한다. 처음에 엔진단에서 진행되는 초기화 과정을 마친 뒤 HandleMatchAssignmentIfNotExpectingOne 함수가 실행되어야 되기 때문에 다음 틱에 실행하도록 하는 것 같다.

void ALyraGameMode::HandleMatchAssignmentIfNotExpectingOne()
{
	FPrimaryAssetId ExperienceId;
	FString ExperienceIdSource;

	// Precedence order (highest wins)
	//  - Matchmaking assignment (if present)
	//  - URL Options override
	//  - Developer Settings (PIE only)
	//  - Command Line override
	//  - World Settings
	//  - Dedicated server
	//  - Default experience

	UWorld* World = GetWorld();

	if (!ExperienceId.IsValid() && World->IsPlayInEditor())
	{
		ExperienceId = GetDefault<ULyraDeveloperSettings>()->ExperienceOverride;
		ExperienceIdSource = TEXT("DeveloperSettings");
	}

	// Final fallback to the default experience
	if (!ExperienceId.IsValid())
	{
		if (TryDedicatedServerLogin())
		{
			// This will start to host as a dedicated server
			return;
		}

		//@TODO: Pull this from a config setting or something
		ExperienceId = FPrimaryAssetId(FPrimaryAssetType("LyraExperienceDefinition"), FName("B_LyraDefaultExperience"));
		ExperienceIdSource = TEXT("Default");
	}

	OnMatchAssignmentGiven(ExperienceId, ExperienceIdSource);
}

HandleMatchAssignmentIfNotExpectingOne함수에서는 ExperienceId와 ExperienceIdSource라는 정보를 현재 상황에 맞도록 채워준 뒤 OnMatchAssignmentGiven함수를 호출해준다.

원래는 if문이 2개가 아니라 더 많지만 실행할 때 걸리는 곳은 저 두 부분이다.

사전에 무언가 추가적인 작업을 통해 다른 if문에 걸리게 할 수 있는 것 같다.

 

처음 if문에서는 GetDefault함수를 통해 ULyraDeveloperSettings의 CDO의 ExperienceOverride를 가져와서 ExperienceId를 할당하려는 시도를 하는데, ULyraDeveloperSettings의 생성자에서 ExperienceOverride를 채워놓지 않아서 ExperienceId는 여전히 채워지지 않는다.

 

두번째 if문에서는 현재 데디케이티드 서버가 아니기 때문에 안에있는 if문은 건너뛰고 ExperienceId와 ExperienceIdSource를 채워준다.

ExperienceId = FPrimaryAssetId(FPrimaryAssetType("LyraExperienceDefinition"), FName("B_LyraDefaultExperience"));

FPrimaryAssetId 구조체의 생성자에 FPrimaryAssetType("LyraExperienceDefinition"), FName("B_LyraDefaultExperience")를 넣어준다.

 

FPrimaryAssetId 의 구조부터 먼저 살펴보면

/**
 * This identifies an object as a "primary" asset that can be searched for by the AssetManager and used in various tools
 */
struct FPrimaryAssetId
{
	/** An FName describing the logical type of this object, usually the name of a base UClass. For example, any Blueprint derived from APawn will have a Primary Asset Type of "Pawn".
	"PrimaryAssetType:PrimaryAssetName" should form a unique name across your project. */
	FPrimaryAssetType PrimaryAssetType;
	/** An FName describing this asset. This is usually the short name of the object, but could be a full asset path for things like maps, or objects with GetPrimaryId() overridden.
	"PrimaryAssetType:PrimaryAssetName" should form a unique name across your project. */
	FName PrimaryAssetName;
    
    FPrimaryAssetId(FPrimaryAssetType InAssetType, FName InAssetName)
	: PrimaryAssetType(InAssetType), PrimaryAssetName(InAssetName)
	{}
    
    // 나머지 생략
}

주석을 읽어보면 FPrimaryAssetId 는 AssetManager에 기본으로 등록하여 사용할 수 있다고 하는 것 같다.

PrimaryAssetType은 객체의 클래스 이름, PrimaryAssetName는 객체를 설명하는 이름인 것 같다.

FPrimaryAssetType을 살펴보면 이 친구도 결국 FName이라는 것을 확인할 수 있다.

 

결론적으로 FPrimaryAssetId라는 것은 2개의 FName을 가지고 있고, 이 FName은 각각 어떤 애셋의 클래스 이름, 실제 애셋 객체를 설명하는 이름을 가지고 있다.

 

에디터의 Project Settings의 Asset Manager에 가보면 Primary Asset Type에 LyraExperienceDefinition가 저장되어 있고, 아래에 B_LyraDefaultExperience애셋도 들어가 있는 것을 확인할 수 있다.

 

지금까지의 내용으로 추측해보면 B_LyraDefaultExperience라는 애셋을 사용하기 위해 FPrimaryAssetId에 정보를 채워넣은 것 같다.

B_LyraDefaultExperience를 살펴보니 Pawn Data, IMC등이 있는데 IMC는 또 Add Input Mapping이라는 Actions중 하나에 들어있다. 이게 대체 무엇일까?

/**
 * Definition of an experience
 */
UCLASS(BlueprintType, Const)
class ULyraExperienceDefinition : public UPrimaryDataAsset
{
	GENERATED_BODY()

public:
	ULyraExperienceDefinition();

	//~UObject interface
#if WITH_EDITOR
	virtual EDataValidationResult IsDataValid(class FDataValidationContext& Context) const override;
#endif
	//~End of UObject interface

	//~UPrimaryDataAsset interface
#if WITH_EDITORONLY_DATA
	virtual void UpdateAssetBundleData() override;
#endif
	//~End of UPrimaryDataAsset interface

public:
	// List of Game Feature Plugins this experience wants to have active
	UPROPERTY(EditDefaultsOnly, Category = Gameplay)
	TArray<FString> GameFeaturesToEnable;

	/** The default pawn class to spawn for players */
	//@TODO: Make soft?
	UPROPERTY(EditDefaultsOnly, Category=Gameplay)
	TObjectPtr<const ULyraPawnData> DefaultPawnData;

	// List of actions to perform as this experience is loaded/activated/deactivated/unloaded
	UPROPERTY(EditDefaultsOnly, Instanced, Category="Actions")
	TArray<TObjectPtr<UGameFeatureAction>> Actions;

	// List of additional action sets to compose into this experience
	UPROPERTY(EditDefaultsOnly, Category=Gameplay)
	TArray<TObjectPtr<ULyraExperienceActionSet>> ActionSets;
};

B_LyraDefaultExperience의 부모 클래스인 ULyraExperienceDefinition를 살펴보면 

Definition of an experience 즉 experience의 정의 라는 주석이 달려있고

GameFeaturesToEnable, DefaultPawnData, Actions, ActionSets라는 구성요소들이 있다.

 

GameFeaturesToEnable는 해당 experience라는 것에 활성화 되어야 하는 플러그인 목록

DefaultPawnData는 이름 그대로 플레이어의 기본 정보

Actions는 experience라는것이 로드/활성화/비활성화/언로드 될때 실행해야 하는 액션

ActionSets는 추가적인 작업이라 하고, ActionSets 는 Actions와 GameFeaturesToEnable로 구성되어있다.

experience라는 개념이 계속 나오고 있는데 이 experience라는 것에 대해 찾아보니 활성화 되야 할 플러그인, 플레이어의 기본 정보인 Pawn Data, experience가 로드될 때 실행되어야 할 액션들로 이루어져있다고 한다.

아직 자세히는 모르겠지만 experience란 데이터 로딩 단위이고 experience가 로드될때 할당 될 여러 데이터들과 기능들로 이루어져있다고 이해하고 있다.

OnMatchAssignmentGiven(ExperienceId, ExperienceIdSource);

void ALyraGameMode::OnMatchAssignmentGiven(FPrimaryAssetId ExperienceId, const FString& ExperienceIdSource)
{
	if (ExperienceId.IsValid())
	{
		UE_LOG(LogLyraExperience, Log, TEXT("Identified experience %s (Source: %s)"), *ExperienceId.ToString(), *ExperienceIdSource);

		ULyraExperienceManagerComponent* ExperienceComponent = GameState->FindComponentByClass<ULyraExperienceManagerComponent>();
		check(ExperienceComponent);
		ExperienceComponent->SetCurrentExperience(ExperienceId);
	}
	else
	{
		UE_LOG(LogLyraExperience, Error, TEXT("Failed to identify experience, loading screen will stay up forever"));
	}
}

다시 GameMode로 돌아가면 B_LyraDefaultExperience라는 experience의 FPrimaryAssetId를 얻어서 

OnMatchAssignmentGiven함수에 넘겨준다.

 

OnMatchAssignmentGiven에서는 해당 experience애셋이 유효하다면 GameState에서 ULyraExperienceManagerComponent를 가져와 SetCurrentExperience함수를 통해 현재 experience를 B_LyraDefaultExperience로 설정해주는 작업을 한다.

ALyraGameMode::ALyraGameMode(const FObjectInitializer& ObjectInitializer)
	: Super(ObjectInitializer)
{
	GameStateClass = ALyraGameState::StaticClass();
	GameSessionClass = ALyraGameSession::StaticClass();
	PlayerControllerClass = ALyraPlayerController::StaticClass();
	ReplaySpectatorPlayerControllerClass = ALyraReplayPlayerController::StaticClass();
	PlayerStateClass = ALyraPlayerState::StaticClass();
	DefaultPawnClass = ALyraCharacter::StaticClass();
	HUDClass = ALyraHUD::StaticClass();
}

그렇다면 다음은 GameState를 살펴보면 될 것 같다.

GameMode의 생성자에서 GameStateClass를 ALyraGameState로 지정했기 때문에 ALyraGameState를 살펴보겠다.

ALyraGameState::ALyraGameState(const FObjectInitializer& ObjectInitializer)
	: Super(ObjectInitializer)
{
	PrimaryActorTick.bCanEverTick = true;
	PrimaryActorTick.bStartWithTickEnabled = true;

	AbilitySystemComponent = ObjectInitializer.CreateDefaultSubobject<ULyraAbilitySystemComponent>(this, TEXT("AbilitySystemComponent"));
	AbilitySystemComponent->SetIsReplicated(true);
	AbilitySystemComponent->SetReplicationMode(EGameplayEffectReplicationMode::Mixed);

	ExperienceManagerComponent = CreateDefaultSubobject<ULyraExperienceManagerComponent>(TEXT("ExperienceManagerComponent"));

	ServerFPS = 0.0f;
}

위에서 GameState에서 ULyraExperienceManagerComponent를 가져오는 작업을 했었는데, ALyraGameState의 생성자에서 ULyraExperienceManagerComponent를 생성해주기 때문에 ALyraGameState의 CDO에는 ULyraExperienceManagerComponent가 할당되어있어서 성공적으로 가져올 수 있었다.

추가적으로 ULyraExperienceManagerComponent는 GameState에서 관리하는 것도 알 수 있다.

 

이제 ULyraExperienceManagerComponent의 SetCurrentExperience를 통해 무슨 작업을 하는지 확인해보면 될 것 같다. ULyraExperienceManagerComponent클래스는 클래스 이름부터 experience를 관리하는 중요한 컴포넌트 클래스인 것 같다.

void ULyraExperienceManagerComponent::SetCurrentExperience(FPrimaryAssetId ExperienceId)
{
	ULyraAssetManager& AssetManager = ULyraAssetManager::Get();
	FSoftObjectPath AssetPath = AssetManager.GetPrimaryAssetPath(ExperienceId);
	TSubclassOf<ULyraExperienceDefinition> AssetClass = Cast<UClass>(AssetPath.TryLoad());
	check(AssetClass);
	const ULyraExperienceDefinition* Experience = GetDefault<ULyraExperienceDefinition>(AssetClass);

	check(Experience != nullptr);
	check(CurrentExperience == nullptr);
	CurrentExperience = Experience;
	StartExperienceLoad();
}

SetCurrentExperience에서는 넘겨받은 ExperienceId를 통해 AssetManager로 B_LyraDefaultExperience의 CDO를 가져와서 CurrentExperience에 채워넣고 StartExperienceLoad함수를 실행해준다.

void ULyraExperienceManagerComponent::StartExperienceLoad()
{
	check(CurrentExperience != nullptr);
	check(LoadState == ELyraExperienceLoadState::Unloaded);

	LoadState = ELyraExperienceLoadState::Loading;
	// 애셋 로드 관련 코드?

	FStreamableDelegate OnAssetsLoadedDelegate = FStreamableDelegate::CreateUObject(this, &ThisClass::OnExperienceLoadComplete);
	if (!Handle.IsValid() || Handle->HasLoadCompleted())
	{
		// Assets were already loaded, call the delegate now
		FStreamableHandle::ExecuteDelegate(OnAssetsLoadedDelegate);
	}
	else
	{
		Handle->BindCompleteDelegate(OnAssetsLoadedDelegate);

		Handle->BindCancelDelegate(FStreamableDelegate::CreateLambda([OnAssetsLoadedDelegate]()
			{
				OnAssetsLoadedDelegate.ExecuteIfBound();
			}));
	}

}

StartExperienceLoad를 살펴보면 LoadState라는 값을 Unloaded에서 Loading으로 바꿔준다.

그리고 위에서는 생략했지만 설정한 CurrentExperience를 통해 관련 애셋들을 로드하고, 로드가 완료됐다면 OnExperienceLoadComplete함수를 실행하는 흐름인 것 같다.

void ULyraExperienceManagerComponent::OnExperienceLoadComplete()
{
	check(LoadState == ELyraExperienceLoadState::Loading);
	check(CurrentExperience != nullptr);

	// 플러그인들 로드 하는듯?

	// Load and activate the features	
	NumGameFeaturePluginsLoading = GameFeaturePluginURLs.Num();
	if (NumGameFeaturePluginsLoading > 0)
	{
		LoadState = ELyraExperienceLoadState::LoadingGameFeatures;
		for (const FString& PluginURL : GameFeaturePluginURLs)
		{
			ULyraExperienceManager::NotifyOfPluginActivation(PluginURL);
			UGameFeaturesSubsystem::Get().LoadAndActivateGameFeaturePlugin(PluginURL, FGameFeaturePluginLoadComplete::CreateUObject(this, &ThisClass::OnGameFeaturePluginLoadComplete));
		}
	}
	else
	{
		OnExperienceFullLoadCompleted();
	}
}

OnExperienceLoadComplete에서는 플러그인들을 로드하는 작업을 하는 것 같다.

그리고 마지막에 두 분기로 나뉘는데, break point를 잡아서 확인해보니 

OnExperienceFullLoadCompleted를 실행하는 블럭으로 들어온다.

 

현재 사용하는 experience에 플러그인을 넣어주지 않아서 이쪽으로 들어오는 것 같다.

위의 블럭에서는 플러그인들이 생성이 완료되면 OnExperienceFullLoadCompleted가 호출 될 것이라고 예상된다.

void ULyraExperienceManagerComponent::OnExperienceFullLoadCompleted()
{
	check(LoadState != ELyraExperienceLoadState::Loaded);
    
	LoadState = ELyraExperienceLoadState::ExecutingActions;
    
	auto ActivateListOfActions = [&Context](const TArray<UGameFeatureAction*>& ActionList)
	{
		for (UGameFeatureAction* Action : ActionList)
		{
			if (Action != nullptr)
			{
				//@TODO: The fact that these don't take a world are potentially problematic in client-server PIE
				// The current behavior matches systems like gameplay tags where loading and registering apply to the entire process,
				// but actually applying the results to actors is restricted to a specific world
				Action->OnGameFeatureRegistering();
				Action->OnGameFeatureLoading();
				Action->OnGameFeatureActivating(Context);
			}
		}
	};

	ActivateListOfActions(CurrentExperience->Actions);
	for (const TObjectPtr<ULyraExperienceActionSet>& ActionSet : CurrentExperience->ActionSets)
	{
		if (ActionSet != nullptr)
		{
			ActivateListOfActions(ActionSet->Actions);
		}
	}

	LoadState = ELyraExperienceLoadState::Loaded;

	OnExperienceLoaded_HighPriority.Broadcast(CurrentExperience);
	OnExperienceLoaded_HighPriority.Clear();

	OnExperienceLoaded.Broadcast(CurrentExperience);
	OnExperienceLoaded.Clear();

	OnExperienceLoaded_LowPriority.Broadcast(CurrentExperience);
	OnExperienceLoaded_LowPriority.Clear();

	// Apply any necessary scalability settings
#if !UE_SERVER
	ULyraSettingsLocal::Get()->OnExperienceLoaded();
#endif
}

OnExperienceFullLoadCompleted에서는 필요한 애셋들이 모두 생성이 된 상태일 것이고

CurrentExperience의 Action들과 CurrentExperience의 ActionSet의 Action들에 있는 함수들을 실행해준다.

기본적으로는 OnGameFeatureRegistering, OnGameFeatureLoading, OnGameFeatureActivating 이 3개의 함수를 실행해 주는 모습이다.

 

그리고 LoadState를 Loaded상태로 바꿔주고, 만약 로드가 완료됐을때 호출될 함수가 있다면 3가지의 우선순위를 두고 순차적으로 호출하는 것을 확인할 수 있다.

 

다시 돌아가서 넣어놓은 Action과 ActionSet을 살펴보면 ActionSet에는 할당된 내용이 없고 Actions에는 AddInputMapping이라는 것이 하나 들어있는 것을 확인할 수 있다.

Add Input Mapping을 살펴보니 UGameFeatureAction_AddInputContextMapping 클래스라는 것을 확인할 수 있었다.

 

그렇다면 결론적으로 OnExperienceFullLoadCompleted안에서 UGameFeatureAction_AddInputContextMappingOnGameFeatureRegistering, OnGameFeatureLoading, OnGameFeatureActivating 이 3개의 함수가 실행되는 것이다.

void UGameFeatureAction_AddInputContextMapping::OnGameFeatureRegistering()
{
	Super::OnGameFeatureRegistering();

	RegisterInputMappingContexts();
}

void UGameFeatureAction_AddInputContextMapping::RegisterInputMappingContexts()
{
	RegisterInputContextMappingsForGameInstanceHandle = FWorldDelegates::OnStartGameInstance.AddUObject(this, &UGameFeatureAction_AddInputContextMapping::RegisterInputContextMappingsForGameInstance);

	const TIndirectArray<FWorldContext>& WorldContexts = GEngine->GetWorldContexts();
	for (TIndirectArray<FWorldContext>::TConstIterator WorldContextIterator = WorldContexts.CreateConstIterator(); WorldContextIterator; ++WorldContextIterator)
	{
		RegisterInputContextMappingsForGameInstance(WorldContextIterator->OwningGameInstance);
	}
}

OnGameFeatureRegistering에서는 바로 RegisterInputMappingContexts를 호출해준다.

GameInstance의 OnStartGameInstance델리게이트에 RegisterInputContextMappingsForGameInstance함수를 등록해주는데, break point를 찍어서 확인해보니 이 함수를 등록하는 시점보다 OnStartGameInstance가 호출되는 시점이 더 빨랐다. 아마도 GameInstance가 변경될때를 대비하여 등록해놓은 것 같다.

 

다음은 순회를 돌면서 RegisterInputContextMappingsForGameInstance를 실행해주는데, 확인해보니 3번의 순회를 돌았고, 각각 Editor, EditorPreview, PIE였다. OwningGameInstance는 PIE에만 채워져 있어서 마지막 순회에서만 예외에 걸리지 않고 다음 함수가 실행되었다. 

이부분은 엔진을 더 분석해봐야 알 수 있을 것 같다. 일단은 현재 환경에서 RegisterInputContextMappingsForGameInstance가 실행된다고 이해하고 넘어갔다.

void UGameFeatureAction_AddInputContextMapping::RegisterInputContextMappingsForGameInstance(UGameInstance* GameInstance)
{
	if (GameInstance != nullptr && !GameInstance->OnLocalPlayerAddedEvent.IsBoundToObject(this))
	{
		GameInstance->OnLocalPlayerAddedEvent.AddUObject(this, &UGameFeatureAction_AddInputContextMapping::RegisterInputMappingContextsForLocalPlayer);
		GameInstance->OnLocalPlayerRemovedEvent.AddUObject(this, &UGameFeatureAction_AddInputContextMapping::UnregisterInputMappingContextsForLocalPlayer);
		
		for (TArray<ULocalPlayer*>::TConstIterator LocalPlayerIterator = GameInstance->GetLocalPlayerIterator(); LocalPlayerIterator; ++LocalPlayerIterator)
		{
			RegisterInputMappingContextsForLocalPlayer(*LocalPlayerIterator);
		}
	}
}

RegisterInputContextMappingsForGameInstance에서는 GameInstance를 통해 LocalPlayer를 얻어와서(일반적으로는 하나만 존재한다.) RegisterInputMappingContextsForLocalPlayer를 실행해준다.

void UGameFeatureAction_AddInputContextMapping::RegisterInputMappingContextsForLocalPlayer(ULocalPlayer* LocalPlayer)
{
	if (ensure(LocalPlayer))
	{
		ULyraAssetManager& AssetManager = ULyraAssetManager::Get();
		
		if (UEnhancedInputLocalPlayerSubsystem* EISubsystem = ULocalPlayer::GetSubsystem<UEnhancedInputLocalPlayerSubsystem>(LocalPlayer))
		{
			if (UEnhancedInputUserSettings* Settings = EISubsystem->GetUserSettings())
			{
				for (const FInputMappingContextAndPriority& Entry : InputMappings)
				{
					// Skip entries that don't want to be registered
					if (!Entry.bRegisterWithSettings)
					{
						continue;
					}

					// Register this IMC with the settings!
					if (UInputMappingContext* IMC = AssetManager.GetAsset(Entry.InputMapping))
					{
						Settings->RegisterInputMappingContext(IMC);
					}
				}
			}
		}
	}
}

RegisterInputMappingContextsForLocalPlayer에서는 드디어 UGameFeatureAction_AddInputContextMapping에 넣어놓은 IMC를 EnhancedInputSystem에 등록해준다.

 

그러면 저번에 Input글에서 LyraHeroComponent에서 블루프린트로 수동으로 할당한 IMC를 없애도 여기서 채워주기때문에 정상적으로 동작하지 않을까? 하고 없애봤더니 없애도 정상적으로 움직였다. 뭔가 퍼즐이 맞춰지는느낌?

 

이제 두번째 함수인 OnGameFeatureLoading를 봤더니 부모클래스에서 정의되어있는데 아무런 내용이 채워져 있지 않아서 패스

 

마지막 3번째 호출되는 OnGameFeatureActivating에서는 Reset이라는 함수를 실행시켜주는데, 자세히는 모르겠고 뭔가 설정값들을 재설정해주는 것 같다.

 

요약하자면

1. GameMode에서 초기화될때 초기 설정에 따라 experience를 얻어온다.

2. 얻어온 experience로 애셋, 플러그인 등을 로드하고 필요한 함수들을 실행한다.

3. 기본적으로는 PawnData를 로드하고 입력과 관련된 Action을 통해 IMC를 등록해주는 작업을 한다.

 

 

 

 

 

 

'언리얼 > Lyra 프로젝트 분석' 카테고리의 다른 글

Lyra 분석 - IGameFrameworkInitStateInterface  (0) 2024.07.10
Input  (0) 2024.07.06
LyraAssetManager  (0) 2024.07.05