보키_기록용

Lyra에서 Input Mapping Context 살펴보기 (2) 본문

언리얼/Enhanced Input System

Lyra에서 Input Mapping Context 살펴보기 (2)

bokki0117 2022. 10. 26. 12:23

내용이 너무 길어져서 나눔. Lyra에서 Input Mapping Context 살펴보기 (1)

 

이전 글에서 PMI 방식 중 GameFeatureData를 사용하는 방식을 설명한다.


Enhanced Input System을 위한 Game Feature 추가하기

 

  • ShooterCore, TopDownArena (UGameFeatureData) :  사실상 방식은 같다.

GameFeatureData[ShooterCore]

ShooterCore

 

 

1. 아래 그림의 빨간줄부분에 들어갈 GameFeatureAction을 상속받은 GameFeatureAction_AddInputConfig를 만든다. 

 

GameFeatureAction_AddInputConfig.h

<hide/>
//GameFeatureAction_AddInputConfig.h
#pragma once

#include "CoreMinimal.h"
#include "GameFeatureAction.h"
#include "Input/DediTestMappableConfigPair.h"
#include "GameFeatureAction_AddInputConfig.generated.h"

/**
 * 
 */
UCLASS(meta=(DisplayName = "Add Input Config"))
class DEDISERVERTEST_API UGameFeatureAction_AddInputConfig : public UGameFeatureAction
{
	GENERATED_BODY()

public:

	virtual void OnGameFeatureRegistering() override;
	virtual void OnGameFeatureActivating(FGameFeatureActivatingContext& Context) override;
	virtual void OnGameFeatureDeactivating(FGameFeatureDeactivatingContext& Context) override;
	virtual void OnGameFeatureUnregistering() override;

	TArray<FMappableConfigPair> InputConfigs;
};

GameFeatureAction_AddInputConfig.cpp

<hide/>
//GameFeatureAction_AddInputConfig.cpp
#include "GameFeatures/GameFeatureAction_AddInputConfig.h"

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

	for (const FMappableConfigPair& Pair : InputConfigs)
	{
		FMappableConfigPair::RegisterPair(Pair);
	}
}

void UGameFeatureAction_AddInputConfig::OnGameFeatureActivating(FGameFeatureActivatingContext& Context)
{
	Super::OnGameFeatureActivating(Context);

	for (const FMappableConfigPair& Pair : InputConfigs)
	{
		if (Pair.bShouldActivateAutomatically)
		{
			FMappableConfigPair::ActivatePair(Pair);
		}
	}
}

void UGameFeatureAction_AddInputConfig::OnGameFeatureDeactivating(FGameFeatureDeactivatingContext& Context)
{
	Super::OnGameFeatureDeactivating(Context);

	for (const FMappableConfigPair& Pair : InputConfigs)
	{
		FMappableConfigPair::DeactivatePair(Pair);
	}
}

void UGameFeatureAction_AddInputConfig::OnGameFeatureUnregistering()
{
	Super::OnGameFeatureUnregistering();

	for (const FMappableConfigPair& Pair : InputConfigs)
	{
		FMappableConfigPair::UnregisterPair(Pair);
	}
}

 

2. 하다보면 #include "Input/DediTestMappableConfigPair.h"가 없다고 뜰텐데, 해당 파일을 만든다. TArray<FMappableConfigPair> InputConfigs;에서 FMappableConfigPair가 정의된 파일이다. 

TArray<FMappableConfigPair> InputConfigs

DediTestMappableConfigPair.h

<hide/>
//DediTestMappableConfigPair.h

#pragma once

#include "CoreMinimal.h"
#include "CommonInput/Public/CommonInputBaseTypes.h"
#include "GameplayTagsManager.h"

#include "DediTestMappableConfigPair.generated.h"

class UPlayerMappableInputConfig;

/** A container to organize loaded player mappable configs to their CommonUI input type */
USTRUCT(BlueprintType)
struct FLoadedMappableConfigPair
{
	GENERATED_BODY()

	FLoadedMappableConfigPair() = default;
	FLoadedMappableConfigPair(const UPlayerMappableInputConfig* InConfig, ECommonInputType InType, const bool InIsActive)
		: Config(InConfig)
		, Type(InType)
		, bIsActive(InIsActive)
	{}

	/** The player mappable input config that should be applied to the Enhanced Input subsystem */
	UPROPERTY(BlueprintReadOnly, VisibleAnywhere)
	const UPlayerMappableInputConfig* Config = nullptr;

	/** The type of device that this mapping config should be applied to */
	UPROPERTY(BlueprintReadOnly, VisibleAnywhere)
	ECommonInputType Type = ECommonInputType::Count;

	/** If this config is currently active. A config is marked as active when it's owning GFA is active */
	UPROPERTY(BlueprintReadOnly, VisibleAnywhere)
	bool bIsActive = false;
};

/** A container to organize potentially unloaded player mappable configs to their CommonUI input type */
USTRUCT()
struct FMappableConfigPair
{
	GENERATED_BODY()

	FMappableConfigPair() = default;

	UPROPERTY(EditAnywhere)
	TSoftObjectPtr<UPlayerMappableInputConfig> Config;

	/**
	 * The type of config that this is. Useful for filtering out configs by the current input device
	 * for things like the settings screen, or if you only want to apply this config when a certain
	 * input type is being used.
	 */
	UPROPERTY(EditAnywhere)
	ECommonInputType Type = ECommonInputType::Count;

	/**
	 * Container of platform traits that must be set in order for this input to be activated.
	 *
	 * If the platform does not have one of the traits specified it can still be registered, but cannot
	 * be activated.
	 */
	UPROPERTY(EditAnywhere)
	FGameplayTagContainer DependentPlatformTraits;

	/**
	 * If the current platform has any of these traits, then this config will not be actived.
	 */
	UPROPERTY(EditAnywhere)
	FGameplayTagContainer ExcludedPlatformTraits;

	/** If true, then this input config will be activated when it's associated Game Feature is activated.
	 * This is normally the desirable behavior
	 */
	UPROPERTY(EditAnywhere)
	bool bShouldActivateAutomatically = true;

	/** Returns true if this config pair can be activated based on the current platform traits and settings. */
	bool CanBeActivated() const;

	/**
	 * Registers the given config mapping with the settings
	 */
	static bool RegisterPair(const FMappableConfigPair& Pair);

	/**
	 * Activates the given config mapping in the settings. This will also register the mapping
	 * if it hasn't been yet.
	 */
	static bool ActivatePair(const FMappableConfigPair& Pair);

	static void DeactivatePair(const FMappableConfigPair& Pair);

	static void UnregisterPair(const FMappableConfigPair& Pair);
};

 

DediTestMappableConfigPair.cpp

<hide/>
//DediTestMappableConfigPair.cpp
#include "Input/DediTestMappableConfigPair.h"
#include "System/DediTestAssetManager.h"
#include "Settings/DediTestSettingLocal.h"
#include "CommonUISettings.h"
#include "ICommonUIModule.h"
#include "PlayerMappableInputConfig.h"

bool FMappableConfigPair::CanBeActivated() const
{
	const FGameplayTagContainer& PlatformTraits = ICommonUIModule::GetSettings().GetPlatformTraits();

	// If the current platform does NOT have all the dependent traits, then don't activate it
	if (!DependentPlatformTraits.IsEmpty() && !PlatformTraits.HasAll(DependentPlatformTraits))
	{
		return false;
	}

	// If the platform has any of the excluded traits, then we shouldn't activate this config.
	if (!ExcludedPlatformTraits.IsEmpty() && PlatformTraits.HasAny(ExcludedPlatformTraits))
	{
		return false;
	}

	return true;
}

bool FMappableConfigPair::RegisterPair(const FMappableConfigPair& Pair)
{
	UDediTestAssetManager& AssetManager = UDediTestAssetManager::Get();

	if (UDediTestSettingLocal* Settings = UDediTestSettingLocal::Get())
	{
		if(const UPlayerMappableInputConfig* LoadedConfig = AssetManager.GetAsset((Pair.Config)))
		{
			Settings->RegisterInputConfig(Pair.Type, LoadedConfig, false);
			return true;
		}
	}

	return false;
}

bool FMappableConfigPair::ActivatePair(const FMappableConfigPair& Pair)
{
	UDediTestAssetManager& AssetManager = UDediTestAssetManager::Get();
	// Only activate a pair that has been successfully registered
	if (FMappableConfigPair::RegisterPair(Pair) && Pair.CanBeActivated())
	{
		if (UDediTestSettingLocal* Settings = UDediTestSettingLocal::Get())
		{
			if (const UPlayerMappableInputConfig* LoadedConfig = AssetManager.GetAsset(Pair.Config))
			{
				Settings->ActivateInputConfig(LoadedConfig);
				return true;
			}
		}
	}
	return false;
}

void FMappableConfigPair::DeactivatePair(const FMappableConfigPair& Pair)
{
	UDediTestAssetManager& AssetManager = UDediTestAssetManager::Get();

	if (UDediTestSettingLocal* Settings = UDediTestSettingLocal::Get())
	{
		if (const UPlayerMappableInputConfig* LoadedConfig = AssetManager.GetAsset(Pair.Config))
		{
			Settings->DeactivateInputConfig(LoadedConfig);
		}
	}
}

void FMappableConfigPair::UnregisterPair(const FMappableConfigPair& Pair)
{
	UDediTestAssetManager& AssetManager = UDediTestAssetManager::Get();

	if (UDediTestSettingLocal* Settings = UDediTestSettingLocal::Get())
	{
		if (const UPlayerMappableInputConfig* LoadedConfig = AssetManager.GetAsset(Pair.Config))
		{
			Settings->UnregisterInputConfig(LoadedConfig);
		}
	}
}

ECommonInputType때문에 #include "CommonInput/Public/CommonInputBaseTypes.h"을 해줘야 하는데, 해당 플러그인은 Common UI로 검색해서 받으면된다.

 

3. GameUserSettings를 상속받은 DediTestSettingLocal을 만든다. 

DediTestSettingLocal.h

<hide/>
// DediTestSettingLocal.h

#pragma once

#include "CoreMinimal.h"
#include "GameFramework/GameUserSettings.h"
#include "Input/DediTestMappableConfigPair.h"
#include "DediTestSettingLocal.generated.h"

/**
 * 
 */
UCLASS()
class DEDISERVERTEST_API UDediTestSettingLocal : public UGameUserSettings
{
	GENERATED_BODY()


public:

	UDediTestSettingLocal();

	static UDediTestSettingLocal* Get();

	//~UGameUserSettings interface
	//virtual void ApplyNonResolutionSettings() override;
	//~End of UGameUserSettings interface

	//void OnExperienceLoaded();

public:

	DECLARE_EVENT_OneParam(UDediTestSettingLocal, FInputConfigDelegate, const FLoadedMappableConfigPair& /*Config*/);

	/** Delegate called when a new input config has been registered */
	FInputConfigDelegate OnInputConfigRegistered;

	/** Delegate called when a registered input config has been activated */
	FInputConfigDelegate OnInputConfigActivated;

	/** Delegate called when a registered input config has been deactivate */
	FInputConfigDelegate OnInputConfigDeactivated;

	/** Register the given input config with the settings to make it available to the player. */
	void RegisterInputConfig(ECommonInputType Type, const UPlayerMappableInputConfig* NewConfig, const bool bIsActive);

	/** Unregister the given input config. Returns the number of configs removed. */
	int32 UnregisterInputConfig(const UPlayerMappableInputConfig* ConfigToRemove);

	/** Set a registered input config as active */
	void ActivateInputConfig(const UPlayerMappableInputConfig* Config);

	/** Deactivate a registered config */
	void DeactivateInputConfig(const UPlayerMappableInputConfig* Config);

	/** Get all currently registered input configs */
	const TArray<FLoadedMappableConfigPair>& GetAllRegisteredInputConfigs() const { return RegisteredInputConfigs; }

private:

	UPROPERTY(VisibleAnywhere)
	TArray<FLoadedMappableConfigPair> RegisteredInputConfigs;

private:

	//void ReapplyThingsDueToPossibleDeviceProfileChange();
};

DediTestSettingLocal.cpp

<hide/>
// DediTestSettingLocal.cpp

#include "Settings/DediTestSettingLocal.h"

UDediTestSettingLocal::UDediTestSettingLocal()
{
}

UDediTestSettingLocal* UDediTestSettingLocal::Get()
{
	return GEngine ? CastChecked<UDediTestSettingLocal>(GEngine->GetGameUserSettings()) : nullptr;
}

//void UDediTestSettingLocal::ApplyNonResolutionSettings()
//{
//	Super::ApplyNonResolutionSettings();
//
//	//...
//}
//
//void UDediTestSettingLocal::OnExperienceLoaded()
//{
//	ReapplyThingsDueToPossibleDeviceProfileChange();
//}
//
//void UDediTestSettingLocal::ReapplyThingsDueToPossibleDeviceProfileChange()
//{
//	ApplyNonResolutionSettings();
//}

void UDediTestSettingLocal::RegisterInputConfig(ECommonInputType Type, const UPlayerMappableInputConfig* NewConfig, const bool bIsActive)
{
	if (NewConfig)
	{
		const int32 ExistingConfigIdx = RegisteredInputConfigs.IndexOfByPredicate([&NewConfig](const FLoadedMappableConfigPair& Pair) { return Pair.Config == NewConfig; });
		if (ExistingConfigIdx == INDEX_NONE)
		{
			const int32 NumAdded = RegisteredInputConfigs.Add(FLoadedMappableConfigPair(NewConfig, Type, bIsActive));
			if (NumAdded != INDEX_NONE)
			{
				OnInputConfigRegistered.Broadcast(RegisteredInputConfigs[NumAdded]);
			}
		}
	}
}

int32 UDediTestSettingLocal::UnregisterInputConfig(const UPlayerMappableInputConfig* ConfigToRemove)
{
	if (ConfigToRemove)
	{
		const int32 Index = RegisteredInputConfigs.IndexOfByPredicate([&ConfigToRemove](const FLoadedMappableConfigPair& Pair) { return Pair.Config == ConfigToRemove; });
		if (Index != INDEX_NONE)
		{
			RegisteredInputConfigs.RemoveAt(Index);
			return 1;
		}

	}
	return INDEX_NONE;
}

void UDediTestSettingLocal::ActivateInputConfig(const UPlayerMappableInputConfig* Config)
{
	if (Config)
	{
		const int32 ExistingConfigIdx = RegisteredInputConfigs.IndexOfByPredicate([&Config](const FLoadedMappableConfigPair& Pair) { return Pair.Config == Config; });
		if (ExistingConfigIdx != INDEX_NONE)
		{
			RegisteredInputConfigs[ExistingConfigIdx].bIsActive = true;
			OnInputConfigActivated.Broadcast(RegisteredInputConfigs[ExistingConfigIdx]);
		}
	}
}

void UDediTestSettingLocal::DeactivateInputConfig(const UPlayerMappableInputConfig* Config)
{
	if (Config)
	{
		const int32 ExistingConfigIdx = RegisteredInputConfigs.IndexOfByPredicate([&Config](const FLoadedMappableConfigPair& Pair) { return Pair.Config == Config; });
		if (ExistingConfigIdx != INDEX_NONE)
		{
			RegisteredInputConfigs[ExistingConfigIdx].bIsActive = false;
			OnInputConfigDeactivated.Broadcast(RegisteredInputConfigs[ExistingConfigIdx]);
		}
	}
}

OnInputConfigActivated부분은 하는 역할이 없는거같은데 아직 모르겠다...

DediTestSettingLocal는 프로젝트 세팅 > 엔진 > 일반 세팅 > Default Classes > 게임 유저 세팅 클래스에 넣어야한다.

 

4. EnhancedInputComponent를 상속받아 만든 InputComponent에 AddInputMappings()를 추가한다.

DediServerTestInputComponent

<hide/>
// DediServerTestInputComponent.h

UCLASS()
class DEDISERVERTEST_API UDediServerTestInputComponent : public UEnhancedInputComponent
{
	GENERATED_BODY()

public:
	// ...
    	void AddInputMappings(const UTestInputConfig* InputConfig, UEnhancedInputLocalPlayerSubsystem* InputSubsystem) const;
	void RemoveInputMappings(const UTestInputConfig* InputConfig, UEnhancedInputLocalPlayerSubsystem* InputSubsystem) const;
    // ...
};


// DediServerTestInputComponent.cpp

void UDediServerTestInputComponent::AddInputMappings(const UTestInputConfig* InputConfig, UEnhancedInputLocalPlayerSubsystem* InputSubsystem) const
{
	check(InputConfig);
	check(InputSubsystem);

	/*ULyraLocalPlayer* LocalPlayer = InputSubsystem->GetLocalPlayer<ULyraLocalPlayer>();
	check(LocalPlayer);*/

	// Add any registered input mappings from the settings!
	if (UDediTestSettingLocal* LocalSettings = UDediTestSettingLocal::Get())
	{
		// We don't want to ignore keys that were "Down" when we add the mapping context
		// This allows you to die holding a movement key, keep holding while waiting for respawn,
		// and have it be applied after you respawn immediately. Leaving bIgnoreAllPressedKeysUntilRelease
		// to it's default "true" state would require the player to release the movement key,
		// and press it again when they respawn
		FModifyContextOptions Options = {};
		Options.bIgnoreAllPressedKeysUntilRelease = false;

		// Add all registered configs, which will add every input mapping context that is in it
		const TArray<FLoadedMappableConfigPair>& Configs = LocalSettings->GetAllRegisteredInputConfigs();
		for (const FLoadedMappableConfigPair& Pair : Configs)
		{
			if (Pair.bIsActive)
			{
				InputSubsystem->AddPlayerMappableConfig(Pair.Config, Options);
			}
		}

		// Tell enhanced input about any custom keymappings that we have set 
		/*for (const TPair<FName, FKey>& Pair : LocalSettings->GetCustomPlayerInputConfig())
		{
			if (Pair.Key != NAME_None && Pair.Value.IsValid())
			{
				InputSubsystem->AddPlayerMappedKey(Pair.Key, Pair.Value);
			}
		}*/
	}
}

void UDediServerTestInputComponent::RemoveInputMappings(const UTestInputConfig* InputConfig, UEnhancedInputLocalPlayerSubsystem* InputSubsystem) const
{
	check(InputConfig);
	check(InputSubsystem);

	/*ULyraLocalPlayer* LocalPlayer = InputSubsystem->GetLocalPlayer<ULyraLocalPlayer>();
	check(LocalPlayer);*/

	if (UDediTestSettingLocal* LocalSettings = UDediTestSettingLocal::Get())
	{
		// Remove any registered input contexts
		const TArray<FLoadedMappableConfigPair>& Configs = LocalSettings->GetAllRegisteredInputConfigs();
		for (const FLoadedMappableConfigPair& Pair : Configs)
		{
			InputSubsystem->RemovePlayerMappableConfig(Pair.Config);
		}

		// Clear any player mapped keys from enhanced input
		/*for (const TPair<FName, FKey>& Pair : LocalSettings->GetCustomPlayerInputConfig())
		{
			InputSubsystem->RemovePlayerMappedKey(Pair.Key);
		}*/
	}
}

GetCustomPlayerInputConfig()부분은 환경설정에서 커스텀 키바인딩하는 부분인거같은데 Lyra에선 GameSettings 플러그인을 만들어서 하는 방식인거 같다. 나중에 정리.

 

5. HeroComponent로 돌아와서 InitializePlayerInput()쪽에서 AddInputMappings()을 불러준다.

<hide/>
// DediServerTestHeroComponent.cpp

void UDediServerTestHeroComponent::InitializePlayerInput(UInputComponent* PlayerInputComponent)
{
	// ...
   	UEnhancedInputLocalPlayerSubsystem* Subsystem = LP->GetSubsystem<UEnhancedInputLocalPlayerSubsystem>();
	check(Subsystem);
    
    if(const UDediTestPawnExtensionComponent* PawnExtComp = UDediTestPawnExtensionComponent::FindPawnExtensionComponent(Pawn))
	{
		if(const UDediServerTestPawnData* PawnData = PawnExtComp->GetPawnData<UDediServerTestPawnData>())
		{
			if(const UTestInputConfig* InputConfig = PawnData->InputConfig)
            		{
                		UDediServerTestInputComponent* DediServerTestIC = CastChecked<UDediServerTestInputComponent>(PlayerInputComponent);
				DediServerTestIC->AddInputMappings(InputConfig, Subsystem);
                
                		// ...
            		}
         	}
     }
         
    // ...
}

 

그럼 GameFeatureData는 만들었는데, 이걸 어떻게 적용시키냐는 Lyra의 Experience 시스템에 있다.

 

기존의 언리얼 개발 방식은 맵(레벨)마다 GameMode를 다르게 써서 GameMode가 여러개 생기는 방식인데, Lyra의 경우 공통적인 GameMode(프로젝트 세팅 > 프로젝트 > 맵 & 모드 > Default Modes > 기본 게임모드)가 있고, 월드 세팅에 Default Gameplay Experience를 따로 만들어서 이 Experience만 맵(레벨)마다 변경하는 식이다. 

 

1. PrimaryDataAsset을 상속받은 ExperienceDefinition을 만든다.

DediTestExperienceDefinition.h

<hide/>
// DediTestExperienceDefinition.h 

#pragma once

#include "CoreMinimal.h"
#include "Engine/DataAsset.h"
#include "DediTestExperienceDefinition.generated.h"

class UGameFeatureAction;

/**
 * 
 */
UCLASS()
class DEDISERVERTEST_API UDediTestExperienceDefinition : public UPrimaryDataAsset
{
	GENERATED_BODY()

public:
	UDediTestExperienceDefinition();

public:

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

 

2. 월드세팅 만들기 : Default Gameplay Experience를 정의해야한다.

DediTestWorldSettings.h

<hide/>
// DediTestWorldSettings.h

#pragma once

#include "CoreMinimal.h"
#include "GameFramework/WorldSettings.h"
#include "DediTestWorldSettings.generated.h"

class UDediTestExperienceDefinition;

/**
 * 
 */
UCLASS()
class DEDISERVERTEST_API ADediTestWorldSettings : public AWorldSettings
{
	GENERATED_BODY()

public:

	ADediTestWorldSettings(const FObjectInitializer& ObjectInitializer);

	FPrimaryAssetId GetDefaultGameplayExperience() const;

protected:

	// The default experience to use when a server opens this map if it is not overridden by the user-facing experience
	UPROPERTY(EditDefaultsOnly, Category = GameMode)
	TSoftClassPtr<UDediTestExperienceDefinition> DefaultGameplayExperience;
};

DediTestWorldSettings.cpp

<hide/>
// DediTestWorldSettings.cpp

#include "Online/DediTestWorldSettings.h"
#include "DediServerTestLogChannels.h"
#include "Engine/AssetManager.h"
#include "Online/DediTestExperienceDefinition.h"

ADediTestWorldSettings::ADediTestWorldSettings(const FObjectInitializer& ObjectInitializer)
{
}

FPrimaryAssetId ADediTestWorldSettings::GetDefaultGameplayExperience() const
{
	FPrimaryAssetId Result;
	if (!DefaultGameplayExperience.IsNull())
	{
		Result = UAssetManager::Get().GetPrimaryAssetIdForPath(DefaultGameplayExperience.ToSoftObjectPath());

		if (!Result.IsValid())
		{
			UE_LOG(LogDediServerTest, Error, TEXT("%s.DefaultGameplayExperience is %s but that failed to resolve into an asset ID (you might need to add a path to the Asset Rules in your game feature plugin or project settings"),
				*GetPathNameSafe(this), *DefaultGameplayExperience.ToString());
		}
	}
	return Result;
}

만들고 나서 프로젝트 세팅 > 엔진 > 일반 세팅 > Default Classes > 월드 세팅 클래스로 가서 만든 클래스를 넣으면

이런식으로 월드 세팅 탭에 Default Gameplay Experience를 설정할 수 있는 탭이 나온다.

 

3. ExperienceManagerComponent 만들기 : GameFeature를 로드하고 활성화한다.

DediExperienceManagerComponent.h

<hide/>
// DediExperienceManagerComponent.h

#pragma once

#include "CoreMinimal.h"
#include "Components/GameStateComponent.h"
#include "GameFeaturePluginOperationResult.h"
#include "DediExperienceManagerComponent.generated.h"

class UDediTestExperienceDefinition;

DECLARE_MULTICAST_DELEGATE_OneParam(FOnDediTestExperienceLoaded, const UDediTestExperienceDefinition* /*Experience*/);

enum class EDediTestExperienceLoadState
{
	Unloaded,
	Loading,
	LoadingGameFeatures,
	LoadingChaosTestingDelay,
	ExecutingActions,
	Loaded,
	Deactivating
};

/**
 * 
 */
UCLASS()
class DEDISERVERTEST_API UDediExperienceManagerComponent : public UGameStateComponent
{
	GENERATED_BODY()

public:

	UDediExperienceManagerComponent(const FObjectInitializer& ObjectInitializer = FObjectInitializer::Get());

	//~UActorComponent interface
	virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override;
	//~End of UActorComponent interface

#if WITH_SERVER_CODE
	void ServerSetCurrentExperience(FPrimaryAssetId ExperienceId);
#endif

	// Ensures the delegate is called once the experience has been loaded
	// If the experience has already loaded, calls the delegate immediately
	void CallOrRegister_OnExperienceLoaded(FOnDediTestExperienceLoaded::FDelegate&& Delegate);

	// Returns true if the experience is fully loaded
	bool IsExperienceLoaded() const;

private:

	UFUNCTION()
	void OnRep_CurrentExperience();

	void StartExperienceLoad();
	void OnExperienceLoadComplete();
	void OnGameFeaturePluginLoadComplete(const UE::GameFeatures::FResult& Result);
	void OnExperienceFullLoadCompleted();

private:

	UPROPERTY(ReplicatedUsing = OnRep_CurrentExperience)
	const UDediTestExperienceDefinition* CurrentExperience;

	EDediTestExperienceLoadState LoadState = EDediTestExperienceLoadState::Unloaded;

	int32 NumGameFeaturePluginsLoading = 0;
	TArray<FString> GameFeaturePluginURLs;

	/**
	 * Delegate called when the experience has finished loading just before others
	 * (e.g., subsystems that set up for regular gameplay)
	 */
	//FOnDediTestExperienceLoaded OnExperienceLoaded_HighPriority;

	/** Delegate called when the experience has finished loading */
	FOnDediTestExperienceLoaded OnExperienceLoaded;

	/** Delegate called when the experience has finished loading */
	//FOnDediTestExperienceLoaded OnExperienceLoaded_LowPriority;
};

DediExperienceManagerComponent.cpp

<hide/>
// DediExperienceManagerComponent.cpp

#include "Online/DediExperienceManagerComponent.h"
#include "Net/UnrealNetwork.h"
#include "TimerManager.h"
#include "DediServerTestLogChannels.h"
#include "System/DediTestAssetManager.h"
#include "Online/DediTestExperienceDefinition.h"
#include "Settings/DediTestSettingLocal.h"
#include "GameFeatureAction.h"
#include "GameFeaturesSubsystemSettings.h"
#include "GameFeaturesSubsystem.h"

namespace DediTestConsoleVariables
{
	static float ExperienceLoadRandomDelayMin = 0.0f;
	static FAutoConsoleVariableRef CVarExperienceLoadRandomDelayMin(
		TEXT("lyra.chaos.ExperienceDelayLoad.MinSecs"),
		ExperienceLoadRandomDelayMin,
		TEXT("This value (in seconds) will be added as a delay of load completion of the experience (along with the random value lyra.chaos.ExperienceDelayLoad.RandomSecs)"),
		ECVF_Default);

	static float ExperienceLoadRandomDelayRange = 0.0f;
	static FAutoConsoleVariableRef CVarExperienceLoadRandomDelayRange(
		TEXT("lyra.chaos.ExperienceDelayLoad.RandomSecs"),
		ExperienceLoadRandomDelayRange,
		TEXT("A random amount of time between 0 and this value (in seconds) will be added as a delay of load completion of the experience (along with the fixed value lyra.chaos.ExperienceDelayLoad.MinSecs)"),
		ECVF_Default);

	float GetExperienceLoadDelayDuration()
	{
		return FMath::Max(0.0f, ExperienceLoadRandomDelayMin + FMath::FRand() * ExperienceLoadRandomDelayRange);
	}
}

void UDediExperienceManagerComponent::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
	Super::GetLifetimeReplicatedProps(OutLifetimeProps);

	DOREPLIFETIME(ThisClass, CurrentExperience);
}

UDediExperienceManagerComponent::UDediExperienceManagerComponent(const FObjectInitializer& ObjectInitializer)
	: Super(ObjectInitializer)
{
	SetIsReplicatedByDefault(true);
}

void UDediExperienceManagerComponent::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
	Super::EndPlay(EndPlayReason);

	// deactivate any features this experience loaded
	//@TODO: This should be handled FILO as well
	for (const FString& PluginURL : GameFeaturePluginURLs)
	{
		UGameFeaturesSubsystem::Get().DeactivateGameFeaturePlugin(PluginURL);
	}
}

#if WITH_SERVER_CODE
void UDediExperienceManagerComponent::ServerSetCurrentExperience(FPrimaryAssetId ExperienceId)
{
	UDediTestAssetManager& AssetManager = UDediTestAssetManager::Get();
	FSoftObjectPath AssetPath = AssetManager.GetPrimaryAssetPath(ExperienceId);
	TSubclassOf<UDediTestExperienceDefinition> AssetClass = Cast<UClass>(AssetPath.TryLoad());
	check(AssetClass);
	const UDediTestExperienceDefinition* Experience = GetDefault<UDediTestExperienceDefinition>(AssetClass);

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

	StartExperienceLoad();
}
#endif

void UDediExperienceManagerComponent::CallOrRegister_OnExperienceLoaded(FOnDediTestExperienceLoaded::FDelegate&& Delegate)
{
	if (IsExperienceLoaded())
	{
		Delegate.Execute(CurrentExperience);
	}
	else
	{
		OnExperienceLoaded.Add(MoveTemp(Delegate));
	}
}

bool UDediExperienceManagerComponent::IsExperienceLoaded() const
{
	return (LoadState == EDediTestExperienceLoadState::Loaded) && (CurrentExperience != nullptr);
}

void UDediExperienceManagerComponent::OnRep_CurrentExperience()
{
	StartExperienceLoad();
}

void UDediExperienceManagerComponent::StartExperienceLoad()
{
	check(CurrentExperience != nullptr);
	check(LoadState == EDediTestExperienceLoadState::Unloaded);

	UE_LOG(LogDediServerTestExperience, Log, TEXT("EXPERIENCE: StartExperienceLoad(CurrentExperience = %s, %s)"),
		*CurrentExperience->GetPrimaryAssetId().ToString(),
		*GetClientServerContextString(this));

	LoadState = EDediTestExperienceLoadState::Loading;

	UDediTestAssetManager& AssetManager = UDediTestAssetManager::Get();

	TSet<FPrimaryAssetId> BundleAssetList;
	TSet<FSoftObjectPath> RawAssetList;

	BundleAssetList.Add(CurrentExperience->GetPrimaryAssetId());
	/*for (const TObjectPtr<ULyraExperienceActionSet>& ActionSet : CurrentExperience->ActionSets)
	{
		if (ActionSet != nullptr)
		{
			BundleAssetList.Add(ActionSet->GetPrimaryAssetId());
		}
	}*/

	TArray<FName> BundlesToLoad;
	BundlesToLoad.Add(FDediTestBundles::Equipped);

	//@TODO: Centralize this client/server stuff into the LyraAssetManager
	const ENetMode OwnerNetMode = GetOwner()->GetNetMode();
	const bool bLoadClient = GIsEditor || (OwnerNetMode != NM_DedicatedServer);
	const bool bLoadServer = GIsEditor || (OwnerNetMode != NM_Client);
	if (bLoadClient)
	{
		BundlesToLoad.Add(UGameFeaturesSubsystemSettings::LoadStateClient);
	}
	if (bLoadServer)
	{
		BundlesToLoad.Add(UGameFeaturesSubsystemSettings::LoadStateServer);
	}

	const TSharedPtr<FStreamableHandle> BundleLoadHandle = AssetManager.ChangeBundleStateForPrimaryAssets(BundleAssetList.Array(), BundlesToLoad, {}, false, FStreamableDelegate(), FStreamableManager::AsyncLoadHighPriority);
	const TSharedPtr<FStreamableHandle> RawLoadHandle = AssetManager.LoadAssetList(RawAssetList.Array(), FStreamableDelegate(), FStreamableManager::AsyncLoadHighPriority, TEXT("StartExperienceLoad()"));

	// If both async loads are running, combine them
	TSharedPtr<FStreamableHandle> Handle = nullptr;
	if (BundleLoadHandle.IsValid() && RawLoadHandle.IsValid())
	{
		Handle = AssetManager.GetStreamableManager().CreateCombinedHandle({ BundleLoadHandle, RawLoadHandle });
	}
	else
	{
		Handle = BundleLoadHandle.IsValid() ? BundleLoadHandle : RawLoadHandle;
	}

	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();
			}));
	}

	// This set of assets gets preloaded, but we don't block the start of the experience based on it
	TSet<FPrimaryAssetId> PreloadAssetList;
	//@TODO: Determine assets to preload (but not blocking-ly)
	if (PreloadAssetList.Num() > 0)
	{
		AssetManager.ChangeBundleStateForPrimaryAssets(PreloadAssetList.Array(), BundlesToLoad, {});
	}
}

void UDediExperienceManagerComponent::OnExperienceLoadComplete()
{
	check(LoadState == EDediTestExperienceLoadState::Loading);
	check(CurrentExperience != nullptr);

	UE_LOG(LogDediServerTestExperience, Log, TEXT("EXPERIENCE: OnExperienceLoadComplete(CurrentExperience = %s, %s)"),
		*CurrentExperience->GetPrimaryAssetId().ToString(),
		*GetClientServerContextString(this));

	// find the URLs for our GameFeaturePlugins - filtering out dupes and ones that don't have a valid mapping
	GameFeaturePluginURLs.Reset();

	auto CollectGameFeaturePluginURLs = [This = this](const UPrimaryDataAsset* Context, const TArray<FString>& FeaturePluginList)
	{
		for (const FString& PluginName : FeaturePluginList)
		{
			FString PluginURL;
			if (UGameFeaturesSubsystem::Get().GetPluginURLForBuiltInPluginByName(PluginName, /*out*/ PluginURL))
			{
				This->GameFeaturePluginURLs.AddUnique(PluginURL);
			}
			else
			{
				ensureMsgf(false, TEXT("OnExperienceLoadComplete failed to find plugin URL from PluginName %s for experience %s - fix data, ignoring for this run"), *PluginName, *Context->GetPrimaryAssetId().ToString());
			}
		}

		// 		// Add in our extra plugin
		// 		if (!CurrentPlaylistData->GameFeaturePluginToActivateUntilDownloadedContentIsPresent.IsEmpty())
		// 		{
		// 			FString PluginURL;
		// 			if (UGameFeaturesSubsystem::Get().GetPluginURLForBuiltInPluginByName(CurrentPlaylistData->GameFeaturePluginToActivateUntilDownloadedContentIsPresent, PluginURL))
		// 			{
		// 				GameFeaturePluginURLs.AddUnique(PluginURL);
		// 			}
		// 		}
	};

	CollectGameFeaturePluginURLs(CurrentExperience, CurrentExperience->GameFeaturesToEnable);
	/*for (const TObjectPtr<ULyraExperienceActionSet>& ActionSet : CurrentExperience->ActionSets)
	{
		if (ActionSet != nullptr)
		{
			CollectGameFeaturePluginURLs(ActionSet, ActionSet->GameFeaturesToEnable);
		}
	}*/

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

void UDediExperienceManagerComponent::OnGameFeaturePluginLoadComplete(const UE::GameFeatures::FResult& Result)
{
	// decrement the number of plugins that are loading
	NumGameFeaturePluginsLoading--;

	if (NumGameFeaturePluginsLoading == 0)
	{
		OnExperienceFullLoadCompleted();
	}
}

void UDediExperienceManagerComponent::OnExperienceFullLoadCompleted()
{
	check(LoadState != EDediTestExperienceLoadState::Loaded);

	// Insert a random delay for testing (if configured)
	if (LoadState != EDediTestExperienceLoadState::LoadingChaosTestingDelay)
	{
		const float DelaySecs = DediTestConsoleVariables::GetExperienceLoadDelayDuration();
		if (DelaySecs > 0.0f)
		{
			FTimerHandle DummyHandle;

			LoadState = EDediTestExperienceLoadState::LoadingChaosTestingDelay;
			GetWorld()->GetTimerManager().SetTimer(DummyHandle, this, &ThisClass::OnExperienceFullLoadCompleted, DelaySecs, /*bLooping=*/ false);

			return;
		}
	}

	LoadState = EDediTestExperienceLoadState::ExecutingActions;

	// Execute the actions
	FGameFeatureActivatingContext Context;

	// Only apply to our specific world context if set
	const FWorldContext* ExistingWorldContext = GEngine->GetWorldContextFromWorld(GetWorld());
	if (ExistingWorldContext)
	{
		Context.SetRequiredWorldContextHandle(ExistingWorldContext->ContextHandle);
	}

	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 = EDediTestExperienceLoadState::Loaded;

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

	// ... PlayerState에서 AbilitySystem Init시에 사용 ...
	OnExperienceLoaded.Broadcast(CurrentExperience);
	OnExperienceLoaded.Clear();

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

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

뭐가 많은데, 중요한건 OnExperienceLoadComplete()에서 UGameFeaturesSubsystem::Get().LoadAndActivateGameFeaturePlugin()을 통해 GameFeauture를 활성화시키는 것이다.

 

4. Experience Definition을 상속받은 BP클래스를 만든다. (B_DediTestExperience)

안에는 만든 GameFeatureData이름을 넣는다. (Lyra에선 ShooterCore,TopDownArena에 해당)

월드 세팅 > Default Gameplay Experience에 만든 BP를 넣는다.

 

※ 주의

1. 프로젝트 세팅 > 엔진 > 일반 세팅 > Default Classes를 잘 설정했는지 확인

2. 프로젝트 세팅 > 게임 > Game Features > Game Feature Project Policy Class를 설정했는지 확인

(Lyra에서는 직접 상속받아 만든 클래스를 넣었지만 기본으로 넣어도 작동됨)

 

Comments