// Copyright Epic Games, Inc. All Rights Reserved. #include "LyraExperienceManagerComponent.h" #include "Net/UnrealNetwork.h" #include "LyraExperienceDefinition.h" #include "LyraExperienceActionSet.h" #include "LyraExperienceManager.h" #include "GameFeaturesSubsystem.h" #include "System/LyraAssetManager.h" #include "GameFeatureAction.h" #include "GameFeaturesSubsystemSettings.h" #include "TimerManager.h" #include "Settings/LyraSettingsLocal.h" #include "LyraLogChannels.h" //@TODO: Async load the experience definition itself //@TODO: Handle failures explicitly (go into a 'completed but failed' state rather than check()-ing) //@TODO: Do the action phases at the appropriate times instead of all at once //@TODO: Support deactivating an experience and do the unloading actions //@TODO: Think about what deactivation/cleanup means for preloaded assets //@TODO: Handle deactivating game features, right now we 'leak' them enabled // (for a client moving from experience to experience we actually want to diff the requirements and only unload some, not unload everything for them to just be immediately reloaded) //@TODO: Handle both built-in and URL-based plugins (search for colon?) namespace LyraConsoleVariables { 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); } } ULyraExperienceManagerComponent::ULyraExperienceManagerComponent(const FObjectInitializer& ObjectInitializer) : Super(ObjectInitializer) { SetIsReplicatedByDefault(true); } #if WITH_SERVER_CODE void ULyraExperienceManagerComponent::ServerSetCurrentExperience(FPrimaryAssetId ExperienceId) { ULyraAssetManager& AssetManager = ULyraAssetManager::Get(); FSoftObjectPath AssetPath = AssetManager.GetPrimaryAssetPath(ExperienceId); TSubclassOf AssetClass = Cast(AssetPath.TryLoad()); check(AssetClass); const ULyraExperienceDefinition* Experience = GetDefault(AssetClass); check(Experience != nullptr); check(CurrentExperience == nullptr); CurrentExperience = Experience; StartExperienceLoad(); } #endif void ULyraExperienceManagerComponent::CallOrRegister_OnExperienceLoaded_HighPriority(FOnLyraExperienceLoaded::FDelegate&& Delegate) { if (IsExperienceLoaded()) { Delegate.Execute(CurrentExperience); } else { OnExperienceLoaded_HighPriority.Add(MoveTemp(Delegate)); } } void ULyraExperienceManagerComponent::CallOrRegister_OnExperienceLoaded(FOnLyraExperienceLoaded::FDelegate&& Delegate) { if (IsExperienceLoaded()) { Delegate.Execute(CurrentExperience); } else { OnExperienceLoaded.Add(MoveTemp(Delegate)); } } void ULyraExperienceManagerComponent::CallOrRegister_OnExperienceLoaded_LowPriority(FOnLyraExperienceLoaded::FDelegate&& Delegate) { if (IsExperienceLoaded()) { Delegate.Execute(CurrentExperience); } else { OnExperienceLoaded_LowPriority.Add(MoveTemp(Delegate)); } } const ULyraExperienceDefinition* ULyraExperienceManagerComponent::GetCurrentExperienceChecked() const { check(LoadState == ELyraExperienceLoadState::Loaded); check(CurrentExperience != nullptr); return CurrentExperience; } bool ULyraExperienceManagerComponent::IsExperienceLoaded() const { return (LoadState == ELyraExperienceLoadState::Loaded) && (CurrentExperience != nullptr); } void ULyraExperienceManagerComponent::OnRep_CurrentExperience() { StartExperienceLoad(); } void ULyraExperienceManagerComponent::StartExperienceLoad() { check(CurrentExperience != nullptr); check(LoadState == ELyraExperienceLoadState::Unloaded); UE_LOG(LogLyraExperience, Log, TEXT("EXPERIENCE: StartExperienceLoad(CurrentExperience = %s, %s)"), *CurrentExperience->GetPrimaryAssetId().ToString(), *GetClientServerContextString(this)); LoadState = ELyraExperienceLoadState::Loading; ULyraAssetManager& AssetManager = ULyraAssetManager::Get(); TSet BundleAssetList; TSet RawAssetList; BundleAssetList.Add(CurrentExperience->GetPrimaryAssetId()); for (const TObjectPtr& ActionSet : CurrentExperience->ActionSets) { if (ActionSet != nullptr) { BundleAssetList.Add(ActionSet->GetPrimaryAssetId()); } } // Load assets associated with the experience TArray BundlesToLoad; BundlesToLoad.Add(FLyraBundles::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 BundleLoadHandle = AssetManager.ChangeBundleStateForPrimaryAssets(BundleAssetList.Array(), BundlesToLoad, {}, false, FStreamableDelegate(), FStreamableManager::AsyncLoadHighPriority); const TSharedPtr RawLoadHandle = AssetManager.LoadAssetList(RawAssetList.Array(), FStreamableDelegate(), FStreamableManager::AsyncLoadHighPriority, TEXT("StartExperienceLoad()")); // If both async loads are running, combine them TSharedPtr 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 PreloadAssetList; //@TODO: Determine assets to preload (but not blocking-ly) if (PreloadAssetList.Num() > 0) { AssetManager.ChangeBundleStateForPrimaryAssets(PreloadAssetList.Array(), BundlesToLoad, {}); } } void ULyraExperienceManagerComponent::OnExperienceLoadComplete() { check(LoadState == ELyraExperienceLoadState::Loading); check(CurrentExperience != nullptr); UE_LOG(LogLyraExperience, 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& 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& ActionSet : CurrentExperience->ActionSets) { if (ActionSet != nullptr) { CollectGameFeaturePluginURLs(ActionSet, ActionSet->GameFeaturesToEnable); } } // 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(); } } void ULyraExperienceManagerComponent::OnGameFeaturePluginLoadComplete(const UE::GameFeatures::FResult& Result) { // decrement the number of plugins that are loading NumGameFeaturePluginsLoading--; if (NumGameFeaturePluginsLoading == 0) { OnExperienceFullLoadCompleted(); } } void ULyraExperienceManagerComponent::OnExperienceFullLoadCompleted() { check(LoadState != ELyraExperienceLoadState::Loaded); // Insert a random delay for testing (if configured) if (LoadState != ELyraExperienceLoadState::LoadingChaosTestingDelay) { const float DelaySecs = LyraConsoleVariables::GetExperienceLoadDelayDuration(); if (DelaySecs > 0.0f) { FTimerHandle DummyHandle; LoadState = ELyraExperienceLoadState::LoadingChaosTestingDelay; GetWorld()->GetTimerManager().SetTimer(DummyHandle, this, &ThisClass::OnExperienceFullLoadCompleted, DelaySecs, /*bLooping=*/ false); return; } } LoadState = ELyraExperienceLoadState::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& 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& 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 } void ULyraExperienceManagerComponent::OnActionDeactivationCompleted() { check(IsInGameThread()); ++NumObservedPausers; if (NumObservedPausers == NumExpectedPausers) { OnAllActionsDeactivated(); } } void ULyraExperienceManagerComponent::GetLifetimeReplicatedProps(TArray& OutLifetimeProps) const { Super::GetLifetimeReplicatedProps(OutLifetimeProps); DOREPLIFETIME(ThisClass, CurrentExperience); } void ULyraExperienceManagerComponent::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) { if (ULyraExperienceManager::RequestToDeactivatePlugin(PluginURL)) { UGameFeaturesSubsystem::Get().DeactivateGameFeaturePlugin(PluginURL); } } //@TODO: Ensure proper handling of a partially-loaded state too if (LoadState == ELyraExperienceLoadState::Loaded) { LoadState = ELyraExperienceLoadState::Deactivating; // Make sure we won't complete the transition prematurely if someone registers as a pauser but fires immediately NumExpectedPausers = INDEX_NONE; NumObservedPausers = 0; // Deactivate and unload the actions FGameFeatureDeactivatingContext Context(FSimpleDelegate::CreateUObject(this, &ThisClass::OnActionDeactivationCompleted)); const FWorldContext* ExistingWorldContext = GEngine->GetWorldContextFromWorld(GetWorld()); if (ExistingWorldContext) { Context.SetRequiredWorldContextHandle(ExistingWorldContext->ContextHandle); } auto DeactivateListOfActions = [&Context](const TArray& ActionList) { for (UGameFeatureAction* Action : ActionList) { if (Action) { Action->OnGameFeatureDeactivating(Context); Action->OnGameFeatureUnregistering(); } } }; DeactivateListOfActions(CurrentExperience->Actions); for (const TObjectPtr& ActionSet : CurrentExperience->ActionSets) { if (ActionSet != nullptr) { DeactivateListOfActions(ActionSet->Actions); } } NumExpectedPausers = Context.GetNumPausers(); if (NumExpectedPausers > 0) { UE_LOG(LogLyraExperience, Error, TEXT("Actions that have asynchronous deactivation aren't fully supported yet in Lyra experiences")); } if (NumExpectedPausers == NumObservedPausers) { OnAllActionsDeactivated(); } } } bool ULyraExperienceManagerComponent::ShouldShowLoadingScreen(FString& OutReason) const { if (LoadState != ELyraExperienceLoadState::Loaded) { OutReason = TEXT("Experience still loading"); return true; } else { return false; } } void ULyraExperienceManagerComponent::OnAllActionsDeactivated() { //@TODO: We actually only deactivated and didn't fully unload... LoadState = ELyraExperienceLoadState::Unloaded; CurrentExperience = nullptr; //@TODO: GEngine->ForceGarbageCollection(true); }