// © 2024 Long Ly. All rights reserved. Any unauthorized use, reproduction, or distribution of this trademark is strictly prohibited and may result in legal action. #include "GameModes/OLSExperienceManagerComponent.h" #include "GameFeatureAction.h" #include "GameModes/OLSExperienceManager.h" #include "GameFeaturesSubsystem.h" #include "GameFeatureAction.h" #include "GameFeaturesSubsystemSettings.h" #include "TimerManager.h" #include "DataAssets/OLSExperienceActionSetDataAsset.h" #include "DataAssets/OLSExperienceDefinitionDataAsset.h" #include "Net/UnrealNetwork.h" #include "Systems/OLSAssetManager.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 OLSConsoleVariables { 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); } } UOLSExperienceManagerComponent::UOLSExperienceManagerComponent(const FObjectInitializer& objectInitializer) : Super(objectInitializer) { SetIsReplicatedByDefault(true); } void UOLSExperienceManagerComponent::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 (UOLSExperienceManager::RequestToDeactivatePlugin(PluginURL)) { UGameFeaturesSubsystem::Get().DeactivateGameFeaturePlugin(PluginURL); } } //@TODO: Ensure proper handling of a partially-loaded state too if (LoadState == EOLSExperienceLoadState::Loaded) { LoadState = EOLSExperienceLoadState::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(TEXT(""), [this](FStringView) { this->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); // @Todo implement this. // for (const TObjectPtr& ActionSet : CurrentExperience->ActionSets) // { // if (ActionSet != nullptr) // { // DeactivateListOfActions(ActionSet->Actions); // } // } NumExpectedPausers = context.GetNumPausers(); if (NumExpectedPausers > 0) { // @Todo replace this with our custom. // UE_LOG(LogLyraExperience, Error, TEXT("Actions that have asynchronous deactivation aren't fully supported yet in Lyra experiences")); } if (NumExpectedPausers == NumObservedPausers) { OnAllActionsDeactivated(); } } } void UOLSExperienceManagerComponent::GetLifetimeReplicatedProps(TArray& OutLifetimeProps) const { Super::GetLifetimeReplicatedProps(OutLifetimeProps); DOREPLIFETIME(ThisClass, CurrentExperience); } bool UOLSExperienceManagerComponent::ShouldShowLoadingScreen(FString& outReason) const { if (LoadState != EOLSExperienceLoadState::Loaded) { outReason = TEXT("Experience still loading"); return true; } return false; } const UOLSExperienceDefinitionDataAsset* UOLSExperienceManagerComponent::GetCurrentExperienceChecked() const { check(LoadState == EOLSExperienceLoadState::Loaded); check(CurrentExperience != nullptr); return CurrentExperience; } void UOLSExperienceManagerComponent::SetCurrentExperience(FPrimaryAssetId experienceId) { UOLSAssetManager& assetManager = UOLSAssetManager::Get(); FSoftObjectPath assetPath = assetManager.GetPrimaryAssetPath(experienceId); TSubclassOf assetClass = Cast(assetPath.TryLoad()); check(assetClass); const UOLSExperienceDefinitionDataAsset* experience = GetDefault(assetClass); check(experience != nullptr); check(CurrentExperience == nullptr); CurrentExperience = experience; StartExperienceLoad(); } bool UOLSExperienceManagerComponent::IsExperienceLoaded() const { return (LoadState == EOLSExperienceLoadState::Loaded) && (CurrentExperience != nullptr); } void UOLSExperienceManagerComponent::CallOrRegister_OnExperienceLoaded_HighPriority( FOnOLSExperienceLoaded::FDelegate&& delegate) { if (IsExperienceLoaded()) { delegate.Execute(CurrentExperience); } else { OnExperienceLoaded_HighPriority.Add(MoveTemp(delegate)); } } void UOLSExperienceManagerComponent::CallOrRegister_OnExperienceLoaded(FOnOLSExperienceLoaded::FDelegate&& delegate) { if (IsExperienceLoaded()) { delegate.Execute(CurrentExperience); } else { OnExperienceLoaded.Add(MoveTemp(delegate)); } } void UOLSExperienceManagerComponent::CallOrRegister_OnExperienceLoaded_LowPriority( FOnOLSExperienceLoaded::FDelegate&& delegate) { if (IsExperienceLoaded()) { delegate.Execute(CurrentExperience); } else { OnExperienceLoaded_LowPriority.Add(MoveTemp(delegate)); } } void UOLSExperienceManagerComponent::OnRep_CurrentExperience() { StartExperienceLoad(); } void UOLSExperienceManagerComponent::StartExperienceLoad() { check(CurrentExperience != nullptr); check(LoadState == EOLSExperienceLoadState::Unloaded); // @Todo replace this by custom log. // UE_LOG(LogLyraExperience, Log, TEXT("EXPERIENCE: StartExperienceLoad(CurrentExperience = %s, %s)"), // *CurrentExperience->GetPrimaryAssetId().ToString(), // *GetClientServerContextString(this)); LoadState = EOLSExperienceLoadState::Loading; UOLSAssetManager& assetManager = UOLSAssetManager::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(FOLSBundles::Equipped); //@TODO: Centralize this client/server stuff into the OLSAssetManager const ENetMode ownerNetMode = GetOwner()->GetNetMode(); const bool shouldLoadClient = GIsEditor || (ownerNetMode != NM_DedicatedServer); const bool shouldLoadServer = GIsEditor || (ownerNetMode != NM_Client); if (shouldLoadClient) { bundlesToLoad.Add(UGameFeaturesSubsystemSettings::LoadStateClient); } if (shouldLoadServer) { bundlesToLoad.Add(UGameFeaturesSubsystemSettings::LoadStateServer); } TSharedPtr bundleLoadHandle = nullptr; if (bundleAssetList.Num() > 0) { bundleLoadHandle = assetManager.ChangeBundleStateForPrimaryAssets(bundleAssetList.Array(), bundlesToLoad, {}, false, FStreamableDelegate(), FStreamableManager::AsyncLoadHighPriority); } TSharedPtr rawLoadHandle = nullptr; if (rawAssetList.Num() > 0) { 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 UOLSExperienceManagerComponent::OnExperienceLoadComplete() { check(LoadState == EOLSExperienceLoadState::Loading); check(CurrentExperience != nullptr); // @Todo replace this by our custom. // 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().GetPluginURLByName(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().GetPluginURLByName(CurrentPlaylistData->GameFeaturePluginToActivateUntilDownloadedContentIsPresent, PluginURL)) // { // GameFeaturePluginURLs.AddUnique(PluginURL); // } // } }; collectGameFeaturePluginURLs(CurrentExperience, CurrentExperience->GameFeaturesToEnable); // @Todo implement this. // 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 = EOLSExperienceLoadState::LoadingGameFeatures; for (const FString& pluginURL : GameFeaturePluginURLs) { UOLSExperienceManager::NotifyOfPluginActivation(pluginURL); UGameFeaturesSubsystem::Get().LoadAndActivateGameFeaturePlugin( pluginURL, FGameFeaturePluginLoadComplete::CreateUObject(this, &ThisClass::OnGameFeaturePluginLoadComplete)); } } else { OnExperienceFullLoadCompleted(); } } void UOLSExperienceManagerComponent::OnGameFeaturePluginLoadComplete(const UE::GameFeatures::FResult& result) { // decrement the number of plugins that are loading NumGameFeaturePluginsLoading--; if (NumGameFeaturePluginsLoading == 0) { OnExperienceFullLoadCompleted(); } } void UOLSExperienceManagerComponent::OnExperienceFullLoadCompleted() { check(LoadState != EOLSExperienceLoadState::Loaded); // Insert a random delay for testing (if configured) if (LoadState != EOLSExperienceLoadState::LoadingChaosTestingDelay) { const float delaySecs = OLSConsoleVariables::GetExperienceLoadDelayDuration(); if (delaySecs > 0.0f) { FTimerHandle dummyHandle; LoadState = EOLSExperienceLoadState::LoadingChaosTestingDelay; GetWorld()->GetTimerManager().SetTimer(dummyHandle, this, &ThisClass::OnExperienceFullLoadCompleted, delaySecs, /*bLooping=*/ false); return; } } LoadState = EOLSExperienceLoadState::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); // @Todo implement this // for (const TObjectPtr& ActionSet : CurrentExperience->ActionSets) // { // if (ActionSet != nullptr) // { // activateListOfActions(ActionSet->Actions); // } // } LoadState = EOLSExperienceLoadState::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 // @Todo implement this // UOLSSettingsLocal::Get()->OnExperienceLoaded(); #endif } void UOLSExperienceManagerComponent::OnActionDeactivationCompleted() { check(IsInGameThread()); ++NumObservedPausers; if (NumObservedPausers == NumExpectedPausers) { OnAllActionsDeactivated(); } } void UOLSExperienceManagerComponent::OnAllActionsDeactivated() { //@TODO: We actually only deactivated and didn't fully unload... LoadState = EOLSExperienceLoadState::Unloaded; CurrentExperience = nullptr; //@TODO: GEngine->ForceGarbageCollection(true); }