diff --git a/Plugins/DirectiveUtilities/Config/DefaultDirectiveUtilities.ini b/Plugins/DirectiveUtilities/Config/DefaultDirectiveUtilities.ini new file mode 100644 index 00000000..3d2b200c --- /dev/null +++ b/Plugins/DirectiveUtilities/Config/DefaultDirectiveUtilities.ini @@ -0,0 +1,31 @@ +[CoreRedirects] +; Redirects Blueprint assets saved with the 1.x class, struct, enum, and package names. ++PackageRedirects=(OldName="/Script/UDCore",NewName="/Script/DirectiveUtilitiesRuntime") ++PackageRedirects=(OldName="/Script/UDCoreEditor",NewName="/Script/DirectiveUtilitiesEditor") ++ClassRedirects=(OldName="/Script/UDCore.UDCoreArrayFunctionLibrary",NewName="/Script/DirectiveUtilitiesRuntime.DirectiveUtilArrayFunctionLibrary") ++ClassRedirects=(OldName="/Script/UDCore.UDCoreFunctionLibrary",NewName="/Script/DirectiveUtilitiesRuntime.DirectiveUtilFunctionLibrary") ++ClassRedirects=(OldName="/Script/UDCore.UDCoreGameplayTagFunctionLibrary",NewName="/Script/DirectiveUtilitiesRuntime.DirectiveUtilGameplayTagFunctionLibrary") ++ClassRedirects=(OldName="/Script/UDCore.UDCoreInputFunctionLibrary",NewName="/Script/DirectiveUtilitiesRuntime.DirectiveUtilInputFunctionLibrary") ++ClassRedirects=(OldName="/Script/UDCore.UDCoreMapFunctionLibrary",NewName="/Script/DirectiveUtilitiesRuntime.DirectiveUtilMapFunctionLibrary") ++ClassRedirects=(OldName="/Script/UDCore.UDCoreMathFunctionLibrary",NewName="/Script/DirectiveUtilitiesRuntime.DirectiveUtilMathFunctionLibrary") ++ClassRedirects=(OldName="/Script/UDCore.UDCoreRegexFunctionLibrary",NewName="/Script/DirectiveUtilitiesRuntime.DirectiveUtilRegexFunctionLibrary") ++ClassRedirects=(OldName="/Script/UDCore.UDCoreSaveGameFunctionLibrary",NewName="/Script/DirectiveUtilitiesRuntime.DirectiveUtilSaveGameFunctionLibrary") ++ClassRedirects=(OldName="/Script/UDCore.UDCoreStringFunctionLibrary",NewName="/Script/DirectiveUtilitiesRuntime.DirectiveUtilStringFunctionLibrary") ++ClassRedirects=(OldName="/Script/UDCore.UDCoreTextFunctionLibrary",NewName="/Script/DirectiveUtilitiesRuntime.DirectiveUtilTextFunctionLibrary") ++ClassRedirects=(OldName="/Script/UDCore.UDAT_Delay",NewName="/Script/DirectiveUtilitiesRuntime.DirectiveUtilTask_Delay") ++ClassRedirects=(OldName="/Script/UDCore.UDAT_AsyncLoadAsset",NewName="/Script/DirectiveUtilitiesRuntime.DirectiveUtilTask_AsyncLoadAsset") ++ClassRedirects=(OldName="/Script/UDCore.UDAT_AsyncLoadClass",NewName="/Script/DirectiveUtilitiesRuntime.DirectiveUtilTask_AsyncLoadClass") ++ClassRedirects=(OldName="/Script/UDCore.UDAT_AsyncLoadAssets",NewName="/Script/DirectiveUtilitiesRuntime.DirectiveUtilTask_AsyncLoadAssets") ++ClassRedirects=(OldName="/Script/UDCore.UDAT_AsyncTrace",NewName="/Script/DirectiveUtilitiesRuntime.DirectiveUtilTask_AsyncTrace") ++ClassRedirects=(OldName="/Script/UDCore.UDAT_MoveToLocation",NewName="/Script/DirectiveUtilitiesRuntime.DirectiveUtilTask_MoveToLocation") ++ClassRedirects=(OldName="/Script/UDCore.UDAT_MoveToActor",NewName="/Script/DirectiveUtilitiesRuntime.DirectiveUtilTask_MoveToActor") ++ClassRedirects=(OldName="/Script/UDCoreEditor.UDCoreEditorActorSubsystem",NewName="/Script/DirectiveUtilitiesEditor.DirectiveUtilEditorActorSubsystem") ++ClassRedirects=(OldName="/Script/UDCoreEditor.UDCoreEditorAssetLibrary",NewName="/Script/DirectiveUtilitiesEditor.DirectiveUtilEditorAssetLibrary") ++StructRedirects=(OldName="/Script/UDCore.UDCoreEnhancedInputContextData",NewName="/Script/DirectiveUtilitiesRuntime.DirectiveUtilEnhancedInputContextData") ++StructRedirects=(OldName="/Script/UDCoreEditor.UDAssetKey",NewName="/Script/DirectiveUtilitiesEditor.DirectiveUtilAssetKey") ++StructRedirects=(OldName="/Script/UDCoreEditor.UDDuplicateAssetData",NewName="/Script/DirectiveUtilitiesEditor.DirectiveUtilDuplicateAssetData") ++EnumRedirects=(OldName="/Script/UDCore.EUDEaseType",NewName="/Script/DirectiveUtilitiesRuntime.EDirectiveUtilEaseType") ++EnumRedirects=(OldName="/Script/UDCore.EUDSuccessStatus",NewName="/Script/DirectiveUtilitiesRuntime.EDirectiveUtilSuccessStatus") ++EnumRedirects=(OldName="/Script/UDCoreEditor.EUDSelectionMethod",NewName="/Script/DirectiveUtilitiesEditor.EDirectiveUtilSelectionMethod") ++EnumRedirects=(OldName="/Script/UDCoreEditor.EUDInclusivity",NewName="/Script/DirectiveUtilitiesEditor.EDirectiveUtilInclusivity") ++EnumRedirects=(OldName="/Script/UDCoreEditor.EUDSearchLocation",NewName="/Script/DirectiveUtilitiesEditor.EDirectiveUtilSearchLocation") diff --git a/Plugins/DirectiveUtilities/Config/FilterPlugin.ini b/Plugins/DirectiveUtilities/Config/FilterPlugin.ini new file mode 100644 index 00000000..a83a8150 --- /dev/null +++ b/Plugins/DirectiveUtilities/Config/FilterPlugin.ini @@ -0,0 +1,13 @@ +[FilterPlugin] +; This section lists additional files which will be packaged along with your plugin. Paths should be listed relative to the root plugin directory, and +; may include "...", "*", and "?" wildcards to match directories, files, and individual characters respectively. +; +; Examples: +; /README.txt +; /Extras/... +; /Binaries/ThirdParty/*.dll +/README.md +/LICENSE +/CHANGELOG.md +/Documentation/... +/Config/DefaultDirectiveUtilities.ini diff --git a/Plugins/DirectiveUtilities/DirectiveUtilities.uplugin b/Plugins/DirectiveUtilities/DirectiveUtilities.uplugin new file mode 100644 index 00000000..36826517 --- /dev/null +++ b/Plugins/DirectiveUtilities/DirectiveUtilities.uplugin @@ -0,0 +1,60 @@ +{ + "FileVersion": 3, + "Version": 3, + "VersionName": "2.0", + "FriendlyName": "Directive Utilities", + "Description": "An open-source Unreal Engine plugin that provides runtime and editor utility nodes for developers.", + "Category": "Unreal Directive", + "CreatedBy": "Unreal Directive", + "CreatedByURL": "https://unrealdirective.com", + "DocsURL": "https://udcore.unrealdirective.com/", + "MarketplaceURL": "https://unrealdirective.com", + "SupportURL": "https://github.com/UnrealDirective/DirectiveUtilities/issues", + "CanContainContent": false, + "IsBetaVersion": false, + "IsExperimentalVersion": false, + "Installed": false, + "Modules": [ + { + "Name": "DirectiveUtilitiesRuntime", + "Type": "Runtime", + "LoadingPhase": "Default", + "PlatformAllowList": [ + "Win64", + "Mac", + "Linux" + ] + }, + { + "Name": "DirectiveUtilitiesEditor", + "Type": "Editor", + "LoadingPhase": "Default", + "TargetAllowList": [ + "Editor" + ] + }, + { + "Name": "DirectiveUtilitiesTests", + "Type": "DeveloperTool", + "LoadingPhase": "PostEngineInit", + "PlatformAllowList": [ + "Win64", + "Mac", + "Linux" + ], + "TargetAllowList": [ + "Editor" + ] + } + ], + "Plugins": [ + { + "Name": "EditorScriptingUtilities", + "Enabled": true + }, + { + "Name": "EnhancedInput", + "Enabled": true + } + ] +} diff --git a/Plugins/DirectiveUtilities/LICENSE b/Plugins/DirectiveUtilities/LICENSE new file mode 100644 index 00000000..da76d0f4 --- /dev/null +++ b/Plugins/DirectiveUtilities/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Unreal Directive + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Plugins/DirectiveUtilities/Resources/Icon128.png b/Plugins/DirectiveUtilities/Resources/Icon128.png new file mode 100644 index 00000000..e05e5bd4 --- /dev/null +++ b/Plugins/DirectiveUtilities/Resources/Icon128.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3cddcdd354ad7ca3046bc092c54f6ec901faa9645c19641421047b3c93198f07 +size 8676 diff --git a/Plugins/DirectiveUtilities/Resources/UDCoreIcon.svg b/Plugins/DirectiveUtilities/Resources/UDCoreIcon.svg new file mode 100644 index 00000000..530cb844 --- /dev/null +++ b/Plugins/DirectiveUtilities/Resources/UDCoreIcon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/DirectiveUtilitiesEditor.Build.cs b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/DirectiveUtilitiesEditor.Build.cs new file mode 100644 index 00000000..e1d09503 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/DirectiveUtilitiesEditor.Build.cs @@ -0,0 +1,34 @@ +// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License. + +using UnrealBuildTool; + +public class DirectiveUtilitiesEditor : ModuleRules +{ + public DirectiveUtilitiesEditor(ReadOnlyTargetRules Target) : base(Target) + { + PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs; + + PublicDependencyModuleNames.AddRange( + new string[] + { + "Core", + "CoreUObject", + "Engine", + "EditorSubsystem", + "EditorScriptingUtilities", + "DirectiveUtilitiesRuntime", + } + ); + + PrivateDependencyModuleNames.AddRange( + new string[] + { + "Slate", + "SlateCore", + "UnrealEd", + "AssetRegistry", + "AssetTools", + } + ); + } +} \ No newline at end of file diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Private/DirectiveUtilitiesEditor.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Private/DirectiveUtilitiesEditor.cpp new file mode 100644 index 00000000..c7ec7563 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Private/DirectiveUtilitiesEditor.cpp @@ -0,0 +1,13 @@ +// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License. + +#include "DirectiveUtilitiesEditor.h" + +void FDirectiveUtilitiesEditorModule::StartupModule() +{ +} + +void FDirectiveUtilitiesEditorModule::ShutdownModule() +{ +} + +IMPLEMENT_MODULE(FDirectiveUtilitiesEditorModule, DirectiveUtilitiesEditor) diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Private/Libraries/DirectiveUtilEditorAssetLibrary.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Private/Libraries/DirectiveUtilEditorAssetLibrary.cpp new file mode 100644 index 00000000..41a125c9 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Private/Libraries/DirectiveUtilEditorAssetLibrary.cpp @@ -0,0 +1,290 @@ +// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License. + + +#include "Libraries/DirectiveUtilEditorAssetLibrary.h" +#include "Subsystems/EditorAssetSubsystem.h" +#include "Algo/Transform.h" +#include "AssetRegistry/IAssetRegistry.h" +#include "AssetRegistry/ARFilter.h" +#include "AssetToolsModule.h" +#include "IAssetTools.h" +#include "Misc/AssetRegistryInterface.h" +#include "Modules/ModuleManager.h" +#include "UObject/ObjectRedirector.h" + +namespace +{ + void WaitForAssetRegistry() + { + if (IAssetRegistry* AssetRegistry = IAssetRegistry::Get()) + { + if (AssetRegistry->IsLoadingAssets()) + { + AssetRegistry->WaitForCompletion(); + } + } + } +} + +TArray UDirectiveUtilEditorAssetLibrary::GetAssetDataListFromDirectory( + const FString& DirectoryPath, + const bool bRecursive) +{ + WaitForAssetRegistry(); + + TArray AssetDataList; + + UEditorAssetSubsystem* EditorAssetSubsystem = GEditor ? GEditor->GetEditorSubsystem() : nullptr; + if (!EditorAssetSubsystem) + { + return AssetDataList; + } + + const TArray AssetPaths = EditorAssetSubsystem->ListAssets(DirectoryPath, bRecursive, false); + + Algo::Transform(AssetPaths, AssetDataList, [EditorAssetSubsystem](const FString& AssetPath) { + return EditorAssetSubsystem->FindAssetData(AssetPath); + }); + + AssetDataList.RemoveAll([](const FAssetData& AssetData) { + return !AssetData.IsValid(); + }); + + return AssetDataList; +} + +TMap UDirectiveUtilEditorAssetLibrary::FindDuplicateAssets( + const TArray& DirectoryPaths, + const bool bRecursive) +{ + TArray CombinedAssetDataList; + + for (const FString& DirectoryPath : DirectoryPaths) + { + const TArray AssetDataList = GetAssetDataListFromDirectory(DirectoryPath, bRecursive); + CombinedAssetDataList.Append(AssetDataList); + } + + TMap DuplicateAssetsMap; + for (const FAssetData& AssetData : CombinedAssetDataList) + { + if (!AssetData.IsValid()) { continue; } + + const FString AssetName = AssetData.AssetName.ToString(); + const FString AssetClass = AssetData.AssetClassPath.ToString(); + const FString AssetPath = AssetData.GetSoftObjectPath().ToString(); + const FDirectiveUtilAssetKey AssetKey(AssetName, AssetClass); + + if (FDirectiveUtilDuplicateAssetData* ExistingData = DuplicateAssetsMap.Find(AssetKey)) + { + ExistingData->DuplicateAssetPaths.AddUnique(AssetPath); + } + else + { + FDirectiveUtilDuplicateAssetData DuplicateAssetData; + DuplicateAssetData.AssetName = AssetName; + DuplicateAssetData.AssetClass = AssetClass; + DuplicateAssetData.DuplicateAssetPaths.Add(AssetPath); + DuplicateAssetsMap.Emplace(AssetKey, MoveTemp(DuplicateAssetData)); + } + } + + TArray KeysToRemove; + for (const auto& Pair : DuplicateAssetsMap) + { + if (Pair.Value.DuplicateAssetPaths.Num() <= 1) + { + KeysToRemove.Add(Pair.Key); + } + } + + for (const FDirectiveUtilAssetKey& Key : KeysToRemove) + { + DuplicateAssetsMap.Remove(Key); + } + + return DuplicateAssetsMap; +} + +EDirectiveUtilSuccessStatus UDirectiveUtilEditorAssetLibrary::FixUpRedirectorsInPaths(const TArray& DirectoryPaths, int32& OutRedirectorsProcessed) +{ + OutRedirectorsProcessed = 0; + + IAssetRegistry* AssetRegistry = IAssetRegistry::Get(); + if (!AssetRegistry) + { + return EDirectiveUtilSuccessStatus::Failure; + } + + if (AssetRegistry->IsLoadingAssets()) + { + AssetRegistry->WaitForCompletion(); + } + + FAssetToolsModule& AssetToolsModule = FModuleManager::LoadModuleChecked("AssetTools"); + IAssetTools& AssetTools = AssetToolsModule.Get(); + if (AssetTools.IsFixupReferencersInProgress()) + { + return EDirectiveUtilSuccessStatus::Failure; + } + + FARFilter Filter; + Filter.bRecursiveClasses = false; + Filter.ClassPaths.Add(UObjectRedirector::StaticClass()->GetClassPathName()); + if (DirectoryPaths.Num() > 0) + { + Filter.bRecursivePaths = true; + for (const FString& DirectoryPath : DirectoryPaths) + { + Filter.PackagePaths.Add(FName(*DirectoryPath)); + } + } + + TArray RedirectorAssets; + if (!AssetRegistry->GetAssets(Filter, RedirectorAssets)) + { + return EDirectiveUtilSuccessStatus::Failure; + } + + TArray Redirectors; + Redirectors.Reserve(RedirectorAssets.Num()); + for (const FAssetData& RedirectorData : RedirectorAssets) + { + if (UObjectRedirector* Redirector = Cast(RedirectorData.GetAsset())) + { + Redirectors.Add(Redirector); + } + } + + if (Redirectors.Num() == 0) + { + return EDirectiveUtilSuccessStatus::Success; + } + + AssetTools.FixupReferencers(Redirectors, false, ERedirectFixupMode::DeleteFixedUpRedirectors); + OutRedirectorsProcessed = Redirectors.Num(); + return EDirectiveUtilSuccessStatus::Success; +} + +TArray UDirectiveUtilEditorAssetLibrary::GetAssetsByClass( + UClass* AssetClass, + const FString& PackagePath, + const bool bRecursiveClasses, + const bool bRecursivePaths, + EDirectiveUtilSuccessStatus& OutStatus) +{ + WaitForAssetRegistry(); + + TArray Result; + OutStatus = EDirectiveUtilSuccessStatus::Failure; + + if (!IsValid(AssetClass)) + { + return Result; + } + + IAssetRegistry* AssetRegistry = IAssetRegistry::Get(); + if (!AssetRegistry) + { + return Result; + } + + FARFilter Filter; + Filter.ClassPaths.Add(AssetClass->GetClassPathName()); + Filter.bRecursiveClasses = bRecursiveClasses; + if (!PackagePath.IsEmpty()) + { + Filter.PackagePaths.Add(FName(*PackagePath)); + Filter.bRecursivePaths = bRecursivePaths; + } + + if (AssetRegistry->GetAssets(Filter, Result)) + { + OutStatus = EDirectiveUtilSuccessStatus::Success; + } + return Result; +} + +TArray UDirectiveUtilEditorAssetLibrary::GetAssetDependencies(const FAssetData& Asset, const bool bHardDependenciesOnly, EDirectiveUtilSuccessStatus& OutStatus) +{ + using namespace UE::AssetRegistry; + + WaitForAssetRegistry(); + + TArray Result; + OutStatus = EDirectiveUtilSuccessStatus::Failure; + + IAssetRegistry* AssetRegistry = IAssetRegistry::Get(); + if (!AssetRegistry || !Asset.IsValid()) + { + return Result; + } + + TArray Dependencies; + const FDependencyQuery Query = bHardDependenciesOnly ? FDependencyQuery(EDependencyQuery::Hard) : FDependencyQuery(); + if (AssetRegistry->GetDependencies(Asset.PackageName, Dependencies, EDependencyCategory::Package, Query)) + { + Result.Reserve(Dependencies.Num()); + for (const FName& Dependency : Dependencies) + { + Result.Add(Dependency.ToString()); + } + OutStatus = EDirectiveUtilSuccessStatus::Success; + } + return Result; +} + +TArray UDirectiveUtilEditorAssetLibrary::GetAssetReferencers(const FAssetData& Asset, const bool bHardReferencesOnly, EDirectiveUtilSuccessStatus& OutStatus) +{ + using namespace UE::AssetRegistry; + + WaitForAssetRegistry(); + + TArray Result; + OutStatus = EDirectiveUtilSuccessStatus::Failure; + + IAssetRegistry* AssetRegistry = IAssetRegistry::Get(); + if (!AssetRegistry || !Asset.IsValid()) + { + return Result; + } + + TArray Referencers; + const FDependencyQuery Query = bHardReferencesOnly ? FDependencyQuery(EDependencyQuery::Hard) : FDependencyQuery(); + if (AssetRegistry->GetReferencers(Asset.PackageName, Referencers, EDependencyCategory::Package, Query)) + { + Result.Reserve(Referencers.Num()); + for (const FName& Referencer : Referencers) + { + Result.Add(Referencer.ToString()); + } + OutStatus = EDirectiveUtilSuccessStatus::Success; + } + return Result; +} + +FString UDirectiveUtilEditorAssetLibrary::GetDefaultAssetNameForClass(UClass* AssetClass, EDirectiveUtilSuccessStatus& OutStatus) +{ + OutStatus = EDirectiveUtilSuccessStatus::Failure; + + if (!IsValid(AssetClass)) + { + return FString(); + } + + FAssetToolsModule& AssetToolsModule = FModuleManager::LoadModuleChecked("AssetTools"); + IAssetTools& AssetTools = AssetToolsModule.Get(); + +#if ENGINE_MAJOR_VERSION == 5 && ENGINE_MINOR_VERSION >= 8 + const TOptional ResolvedName = AssetTools.GetDefaultAssetNameForClass(AssetClass, nullptr, nullptr); +#else + const TOptional ResolvedName = AssetTools.GetDefaultAssetNameForClass(AssetClass); +#endif + + if (ResolvedName.IsSet() && !ResolvedName.GetValue().IsEmpty()) + { + OutStatus = EDirectiveUtilSuccessStatus::Success; + return ResolvedName.GetValue(); + } + return FString(); +} diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Private/Subsystems/DirectiveUtilEditorActorSubsystem.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Private/Subsystems/DirectiveUtilEditorActorSubsystem.cpp new file mode 100644 index 00000000..a112d08e --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Private/Subsystems/DirectiveUtilEditorActorSubsystem.cpp @@ -0,0 +1,1372 @@ +// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License. + +#include "Subsystems/DirectiveUtilEditorActorSubsystem.h" + +#include "DirectiveUtilLogChannels.h" +#include "Engine/StaticMeshActor.h" +#include "Materials/MaterialExpressionTextureObject.h" +#include "EditorViewportClient.h" +#include "Components/BoxComponent.h" +#include "Components/CapsuleComponent.h" +#include "Components/SphereComponent.h" +#include "Materials/MaterialExpressionTextureSample.h" +#include "ScopedTransaction.h" + +namespace +{ + // Adds an actor to OutActors when whether it matches the predicate (evaluated once across all of the + // actor's components) equals whether matches are being included. Centralizes the include/exclude + // aggregation so a multi-component actor is judged per-actor rather than per-component. + template + void FilterActorsByPredicate(const TArray& Actors, TArray& OutActors, + const EDirectiveUtilInclusivity Inclusivity, TPredicate&& ActorMatches) + { + const bool bIncludeMatches = Inclusivity == Include; + + // Filtering an array into itself: snapshot the input and rebuild in place. + TArray AliasedCopy; + const TArray* Source = &Actors; + if (static_cast(&Actors) == static_cast(&OutActors)) + { + AliasedCopy = Actors; + Source = &AliasedCopy; + OutActors.Reset(); + } + + TSet Seen; + Seen.Reserve(OutActors.Num() + Source->Num()); + Seen.Append(OutActors); + + for (TSourceActor* Actor : *Source) + { + if (!Actor || Seen.Contains(Actor)) { continue; } + if (ActorMatches(Actor) == bIncludeMatches) + { + Seen.Add(Actor); + OutActors.Add(Actor); + } + } + } +} + +void UDirectiveUtilEditorActorSubsystem::FocusActorsInViewport(const TArray Actors, const bool bInstant) +{ + if (Actors.Num() == 0) { return; } + if (!GEditor) { return; } + + FViewport* ActiveViewport = GEditor->GetActiveViewport(); + if (!ActiveViewport) { return; } + + FEditorViewportClient* ViewportClient = static_cast(ActiveViewport->GetClient()); + if (!ViewportClient) { return; } + + FBox BoundingBox = FBox(ForceInit); + for (const auto Actor : Actors) + { + if (!Actor) { continue; } + BoundingBox += Actor->GetComponentsBoundingBox(true, true); + } + + ViewportClient->FocusViewportOnBox(BoundingBox, bInstant); +} + +TArray UDirectiveUtilEditorActorSubsystem::GetAllLevelClasses() +{ + TArray ActorClasses; + TArray Actors = GetAllLevelActors(); + + for (const AActor* Actor : Actors) + { + if (!Actor) { continue; } + ActorClasses.AddUnique(Actor->GetClass()); + } + + return ActorClasses; +} + +void UDirectiveUtilEditorActorSubsystem::FilterStaticMeshActors( + TArray& OutStaticMeshActors, + TArray ActorsToFilter) const +{ + TSet Seen; + Seen.Reserve(OutStaticMeshActors.Num() + ActorsToFilter.Num()); + for (AStaticMeshActor* ExistingActor : OutStaticMeshActors) + { + Seen.Add(ExistingActor); + } + + for (AActor* Actor : ActorsToFilter) + { + if (!Actor || Seen.Contains(Actor)) + { + continue; + } + if (Actor->IsA(AStaticMeshActor::StaticClass())) + { + Seen.Add(Actor); + OutStaticMeshActors.Add(Cast(Actor)); + } + } + + UE_LOG(LogDirectiveUtilEditor, Display, TEXT("Filtered %d static mesh actors"), OutStaticMeshActors.Num()); +} + +void UDirectiveUtilEditorActorSubsystem::FilterActorsByName( + const TArray& Actors, + TArray& FilteredActors, + const FString& ActorName, + const EDirectiveUtilInclusivity Inclusivity) +{ + FilterActorsByPredicate(Actors, FilteredActors, Inclusivity, [&](AActor* Actor) -> bool + { + return Actor->GetActorLabel().Contains(ActorName); + }); + + UE_LOG( + LogDirectiveUtilEditor, + Display, + TEXT("Actor Filter: Found %i actors that does %s contain the name [%s]"), + FilteredActors.Num(), + Inclusivity == EDirectiveUtilInclusivity::Include ? TEXT("") : TEXT("not"), + *ActorName); +} + +void UDirectiveUtilEditorActorSubsystem::FilterActorsByClass( + const TArray& Actors, + TArray& FilteredActors, + const TSubclassOf ActorClass, + const EDirectiveUtilInclusivity Inclusivity) +{ + if (!ActorClass) { return; } + + FilterActorsByPredicate(Actors, FilteredActors, Inclusivity, [&](AActor* Actor) -> bool + { + return Actor->IsA(ActorClass); + }); + + UE_LOG( + LogDirectiveUtilEditor, + Display, + TEXT("Actor Filter: Found %i actors that does %s contain the class %s"), + FilteredActors.Num(), + Inclusivity == EDirectiveUtilInclusivity::Include ? TEXT("") : TEXT("not"), + *ActorClass->GetName()); +} + +void UDirectiveUtilEditorActorSubsystem::FilterActorsByTag( + const TArray& Actors, + TArray& FilteredActors, + const FName Tag, + const EDirectiveUtilInclusivity Inclusivity) +{ + FilterActorsByPredicate(Actors, FilteredActors, Inclusivity, [&](AActor* Actor) -> bool + { + return Actor->ActorHasTag(Tag); + }); + + UE_LOG( + LogDirectiveUtilEditor, + Display, + TEXT("Actor Filter: Found %i actors that does %s contain the tag %s"), + FilteredActors.Num(), + Inclusivity == EDirectiveUtilInclusivity::Include ? TEXT("") : TEXT("not"), + *Tag.ToString()); +} + +void UDirectiveUtilEditorActorSubsystem::FilterActorsByMaterialName( + const TArray& Actors, + TArray& FilteredActors, + const FString& MaterialName, + const EDirectiveUtilSearchLocation MaterialSource, + const EDirectiveUtilInclusivity Inclusivity) +{ + FilterActorsByPredicate(Actors, FilteredActors, Inclusivity, [&](AActor* Actor) -> bool + { + TArray StaticMeshComponents; + Actor->GetComponents(StaticMeshComponents, true); + for (const UStaticMeshComponent* StaticMeshComponent : StaticMeshComponents) + { + if (!StaticMeshComponent) { continue; } + + if (MaterialSource == BaseAndOverride || MaterialSource == OverrideOnly) + { + for (int32 i = 0; i < StaticMeshComponent->GetNumMaterials(); i++) + { + const UMaterialInterface* Mat = StaticMeshComponent->GetMaterial(i); + if (Mat && Mat->GetName().Contains(MaterialName)) { return true; } + } + } + + if (MaterialSource == BaseAndOverride || MaterialSource == BaseOnly) + { + if (const UStaticMesh* Mesh = StaticMeshComponent->GetStaticMesh()) + { + for (int32 i = 0; i < Mesh->GetStaticMaterials().Num(); i++) + { + const UMaterialInterface* Mat = Mesh->GetMaterial(i); + if (Mat && Mat->GetName().Contains(MaterialName)) { return true; } + } + } + } + } + return false; + }); + + UE_LOG(LogDirectiveUtilEditor, Display, TEXT("Actor Filter: Found %i actors that does %s contain the material %s"), + FilteredActors.Num(), Inclusivity == EDirectiveUtilInclusivity::Include ? TEXT("") : TEXT("not"), *MaterialName); +} + +void UDirectiveUtilEditorActorSubsystem::FilterActorsByMaterial( + const TArray& Actors, + TArray& FilteredActors, + const TSoftObjectPtr& Material, + const EDirectiveUtilSearchLocation MaterialSource, + const EDirectiveUtilInclusivity Inclusivity) +{ + const UMaterialInterface* TargetMaterial = Material.LoadSynchronous(); + FilterActorsByPredicate(Actors, FilteredActors, Inclusivity, [&](AActor* Actor) -> bool + { + TArray StaticMeshComponents; + Actor->GetComponents(StaticMeshComponents, true); + for (const UStaticMeshComponent* StaticMeshComponent : StaticMeshComponents) + { + if (!StaticMeshComponent) { continue; } + + if (MaterialSource == BaseAndOverride || MaterialSource == OverrideOnly) + { + for (int32 i = 0; i < StaticMeshComponent->GetNumMaterials(); i++) + { + if (StaticMeshComponent->GetMaterial(i) == TargetMaterial) { return true; } + } + } + + if (MaterialSource == BaseAndOverride || MaterialSource == BaseOnly) + { + if (const UStaticMesh* Mesh = StaticMeshComponent->GetStaticMesh()) + { + for (int32 i = 0; i < Mesh->GetStaticMaterials().Num(); i++) + { + if (Mesh->GetMaterial(i) == TargetMaterial) { return true; } + } + } + } + } + return false; + }); + + UE_LOG(LogDirectiveUtilEditor, Display, TEXT("Actor Filter: Found %i actors that does %s contain the material %s"), + FilteredActors.Num(), Inclusivity == EDirectiveUtilInclusivity::Include ? TEXT("") : TEXT("not"), *Material.ToString()); +} + +void UDirectiveUtilEditorActorSubsystem::FilterActorsByStaticMeshName( + const TArray& Actors, + TArray& FilteredActors, + const FString& StaticMeshName, + const EDirectiveUtilInclusivity Inclusivity) +{ + FilterActorsByPredicate(Actors, FilteredActors, Inclusivity, [&](AActor* Actor) -> bool + { + for (const UActorComponent* Component : Actor->GetComponents()) + { + const UStaticMeshComponent* StaticMeshComponent = Cast(Component); + if (!StaticMeshComponent) { continue; } + + const UStaticMesh* Mesh = StaticMeshComponent->GetStaticMesh(); + if (!Mesh) + { + // A null static mesh only counts as a match when filtering for an empty name. + if (StaticMeshName.IsEmpty()) { return true; } + continue; + } + if (Mesh->GetName().Contains(StaticMeshName)) { return true; } + } + return false; + }); + + UE_LOG(LogDirectiveUtilEditor, Display, TEXT("Actor Filter: Found %i actors that does %s contain the static mesh %s"), + FilteredActors.Num(), Inclusivity == EDirectiveUtilInclusivity::Include ? TEXT("") : TEXT("not"), *StaticMeshName); +} + +void UDirectiveUtilEditorActorSubsystem::FilterActorsByStaticMesh( + const TArray& Actors, + TArray& FilteredActors, + const TSoftObjectPtr& StaticMesh, + const EDirectiveUtilInclusivity Inclusivity) +{ + const UStaticMesh* TargetMesh = StaticMesh.LoadSynchronous(); + FilterActorsByPredicate(Actors, FilteredActors, Inclusivity, [&](AActor* Actor) -> bool + { + for (const UActorComponent* Component : Actor->GetComponents()) + { + const UStaticMeshComponent* StaticMeshComponent = Cast(Component); + if (!StaticMeshComponent) { continue; } + + const UStaticMesh* Mesh = StaticMeshComponent->GetStaticMesh(); + if (!Mesh) + { + if (StaticMesh.IsNull()) { return true; } + continue; + } + if (Mesh == TargetMesh) { return true; } + } + return false; + }); + + UE_LOG(LogDirectiveUtilEditor, Display, TEXT("Actor Filter: Found %i actors that does %s contain the static mesh %s"), + FilteredActors.Num(), Inclusivity == EDirectiveUtilInclusivity::Include ? TEXT("") : TEXT("not"), + *StaticMesh.ToString()); +} + +void UDirectiveUtilEditorActorSubsystem::FilterActorsByVertCount( + const TArray& Actors, + TArray& FilteredActors, + const int32 MinVertCount, + const int32 MaxVertCount, + const EDirectiveUtilInclusivity Inclusivity) +{ + FilterActorsByPredicate(Actors, FilteredActors, Inclusivity, [&](AActor* Actor) -> bool + { + TArray StaticMeshComponents; + Actor->GetComponents(StaticMeshComponents, true); + for (const UStaticMeshComponent* StaticMeshComponent : StaticMeshComponents) + { + if (!StaticMeshComponent) { continue; } + const UStaticMesh* Mesh = StaticMeshComponent->GetStaticMesh(); + if (!Mesh || !Mesh->HasValidRenderData(true, 0)) { continue; } + + const int32 VertCount = Mesh->GetNumVertices(0); + if (VertCount >= MinVertCount && VertCount <= MaxVertCount) { return true; } + } + return false; + }); + + UE_LOG(LogDirectiveUtilEditor, Display, TEXT("Actor Filter: Found %i actors that %s between %i and %i vertices"), + FilteredActors.Num(), Inclusivity == EDirectiveUtilInclusivity::Include ? TEXT("has") : TEXT("does not have"), + MinVertCount, MaxVertCount); +} + +void UDirectiveUtilEditorActorSubsystem::FilterActorsByTriCount( + const TArray& Actors, + TArray& FilteredActors, + const int32 MinTriCount, + const int32 MaxTriCount, + const EDirectiveUtilInclusivity Inclusivity) +{ + FilterActorsByPredicate(Actors, FilteredActors, Inclusivity, [&](AActor* Actor) -> bool + { + TArray StaticMeshComponents; + Actor->GetComponents(StaticMeshComponents, true); + for (const UStaticMeshComponent* StaticMeshComponent : StaticMeshComponents) + { + if (!StaticMeshComponent) { continue; } + const UStaticMesh* Mesh = StaticMeshComponent->GetStaticMesh(); + if (!Mesh || !Mesh->HasValidRenderData(true, 0)) { continue; } + + const int32 TriCount = Mesh->GetNumTriangles(0); + if (TriCount >= MinTriCount && TriCount <= MaxTriCount) { return true; } + } + return false; + }); + + UE_LOG(LogDirectiveUtilEditor, Display, TEXT("Actor Filter: Found %i actors that %s between %i and %i triangles"), + FilteredActors.Num(), Inclusivity == EDirectiveUtilInclusivity::Include ? TEXT("has") : TEXT("does not have"), + MinTriCount, MaxTriCount); +} + +void UDirectiveUtilEditorActorSubsystem::FilterActorsByBounds( + const TArray& Actors, + TArray& FilteredActors, + const FVector& MinBounds, + const FVector& MaxBounds, + const EDirectiveUtilInclusivity Inclusivity) +{ + FilterActorsByPredicate(Actors, FilteredActors, Inclusivity, [&](AActor* Actor) -> bool + { + FVector Origin, Extent; + Actor->GetActorBounds(false, Origin, Extent); + const FVector ActorSize = Extent * 2; + return MinBounds.X <= ActorSize.X && ActorSize.X <= MaxBounds.X && + MinBounds.Y <= ActorSize.Y && ActorSize.Y <= MaxBounds.Y && + MinBounds.Z <= ActorSize.Z && ActorSize.Z <= MaxBounds.Z; + }); + + UE_LOG(LogDirectiveUtilEditor, Display, + TEXT("Actor Filter: Found %i actors that %s within the bounds (%f, %f, %f) and (%f, %f, %f)"), + FilteredActors.Num(), Inclusivity == EDirectiveUtilInclusivity::Include ? TEXT("is") : TEXT("is not"), MinBounds.X, + MinBounds.Y, MinBounds.Z, + MaxBounds.X, MaxBounds.Y, MaxBounds.Z); +} + +void UDirectiveUtilEditorActorSubsystem::FilterActorsByStaticMeshBounds( + const TArray& Actors, + TArray& FilteredActors, + const FVector& MinBounds, + const FVector& MaxBounds, + const EDirectiveUtilInclusivity Inclusivity) +{ + FilterActorsByPredicate(Actors, FilteredActors, Inclusivity, [&](AActor* Actor) -> bool + { + for (UActorComponent* Component : Actor->GetComponents()) + { + const UStaticMeshComponent* StaticMeshComponent = Cast(Component); + if (!StaticMeshComponent || !StaticMeshComponent->GetStaticMesh()) { continue; } + + const FVector StaticMeshSize = StaticMeshComponent->GetStaticMesh()->GetBounds().BoxExtent * 2; + if (MinBounds.X <= StaticMeshSize.X && StaticMeshSize.X <= MaxBounds.X && + MinBounds.Y <= StaticMeshSize.Y && StaticMeshSize.Y <= MaxBounds.Y && + MinBounds.Z <= StaticMeshSize.Z && StaticMeshSize.Z <= MaxBounds.Z) { return true; } + } + return false; + }); + + UE_LOG(LogDirectiveUtilEditor, Display, + TEXT("Actor Filter: Found %i actors that %s within the static mesh bounds (%f, %f, %f) and (%f, %f, %f)"), + FilteredActors.Num(), Inclusivity == EDirectiveUtilInclusivity::Include ? TEXT("is") : TEXT("is not"), MinBounds.X, + MinBounds.Y, MinBounds.Z, + MaxBounds.X, MaxBounds.Y, MaxBounds.Z); +} + +void UDirectiveUtilEditorActorSubsystem::FilterActorsByWorldLocation( + const TArray& Actors, + TArray& FilteredActors, + const FVector& WorldLocation, + const float Radius, + const EDirectiveUtilInclusivity Inclusivity) +{ + FilterActorsByPredicate(Actors, FilteredActors, Inclusivity, [&](AActor* Actor) -> bool + { + return FVector::Dist(Actor->GetActorLocation(), WorldLocation) <= Radius; + }); + + UE_LOG(LogDirectiveUtilEditor, Display, + TEXT("Actor Filter: Found %i actors that %s within the world location (%f, %f, %f) with the radius of %f"), + FilteredActors.Num(), Inclusivity == EDirectiveUtilInclusivity::Include ? TEXT("is") : TEXT("is not"), WorldLocation.X, + WorldLocation.Y, WorldLocation.Z, Radius); +} + +void UDirectiveUtilEditorActorSubsystem::FilterActorsByLODCount( + const TArray& Actors, + TArray& FilteredActors, + const int32 MinLODs, + const int32 MaxLODs, + const EDirectiveUtilInclusivity Inclusivity) +{ + FilterActorsByPredicate(Actors, FilteredActors, Inclusivity, [&](AActor* Actor) -> bool + { + TArray StaticMeshComponents; + Actor->GetComponents(StaticMeshComponents, true); + for (const UStaticMeshComponent* StaticMeshComponent : StaticMeshComponents) + { + if (!StaticMeshComponent) { continue; } + const UStaticMesh* StaticMesh = StaticMeshComponent->GetStaticMesh(); + if (!StaticMesh) { continue; } + if (StaticMesh->GetNumLODs() >= MinLODs && StaticMesh->GetNumLODs() <= MaxLODs) { return true; } + } + return false; + }); + + UE_LOG(LogDirectiveUtilEditor, Display, TEXT("Actor Filter: Found %i actors that %s between %i and %i LODs"), + FilteredActors.Num(), Inclusivity == EDirectiveUtilInclusivity::Include ? TEXT("has") : TEXT("does not have"), MinLODs, + MaxLODs); +} + +void UDirectiveUtilEditorActorSubsystem::FilterActorsByNaniteState( + const TArray& Actors, + TArray& FilteredActors, + const bool bNaniteEnabled, + const EDirectiveUtilInclusivity Inclusivity) +{ + FilterActorsByPredicate(Actors, FilteredActors, Inclusivity, [&](AActor* Actor) -> bool + { + TArray StaticMeshComponents; + Actor->GetComponents(StaticMeshComponents, true); + for (const UStaticMeshComponent* StaticMeshComponent : StaticMeshComponents) + { + if (!StaticMeshComponent) { continue; } + const UStaticMesh* Mesh = StaticMeshComponent->GetStaticMesh(); + if (!Mesh) { continue; } +#if ENGINE_MAJOR_VERSION == 5 && ENGINE_MINOR_VERSION >= 7 + const bool bMeshNaniteEnabled = Mesh->GetNaniteSettings().bEnabled; +#else + const bool bMeshNaniteEnabled = Mesh->NaniteSettings.bEnabled; +#endif + if (bMeshNaniteEnabled == bNaniteEnabled) { return true; } + } + return false; + }); + + UE_LOG(LogDirectiveUtilEditor, Display, TEXT("Actor Filter: Found %i actors that %s Nanite enabled"), + FilteredActors.Num(), bNaniteEnabled ? TEXT("has") : TEXT("does not have")); +} + +void UDirectiveUtilEditorActorSubsystem::FilterActorsByLightmapResolution( + const TArray& Actors, + TArray& FilteredActors, + const int32 MinLightmapResolution, + const int32 MaxLightmapResolution, + const EDirectiveUtilSearchLocation SearchLocation, + const EDirectiveUtilInclusivity Inclusivity) +{ + FilterActorsByPredicate(Actors, FilteredActors, Inclusivity, [&](AActor* Actor) -> bool + { + TArray StaticMeshComponents; + Actor->GetComponents(StaticMeshComponents, true); + for (const UStaticMeshComponent* StaticMeshComponent : StaticMeshComponents) + { + if (!StaticMeshComponent) { continue; } + const UStaticMesh* Mesh = StaticMeshComponent->GetStaticMesh(); + if (!Mesh) { continue; } + + if ((SearchLocation == BaseAndOverride || SearchLocation == OverrideOnly) && StaticMeshComponent->bOverrideLightMapRes) + { + const int32 LightmapRes = StaticMeshComponent->OverriddenLightMapRes; + if (LightmapRes >= MinLightmapResolution && LightmapRes <= MaxLightmapResolution) { return true; } + } + if (SearchLocation == BaseAndOverride || SearchLocation == BaseOnly) + { + const int32 LightmapRes = Mesh->GetLightMapResolution(); + if (LightmapRes >= MinLightmapResolution && LightmapRes <= MaxLightmapResolution) { return true; } + } + } + return false; + }); + + UE_LOG(LogDirectiveUtilEditor, Display, + TEXT("Actor Filter: Found %i actors that %s between %i and %i lightmap resolution"), + FilteredActors.Num(), Inclusivity == EDirectiveUtilInclusivity::Include ? TEXT("has") : TEXT("does not have"), + MinLightmapResolution, MaxLightmapResolution); +} + +void UDirectiveUtilEditorActorSubsystem::FilterActorsByMobility( + const TArray& Actors, + TArray& FilteredActors, + const EComponentMobility::Type Mobility, + const EDirectiveUtilInclusivity Inclusivity) +{ + FilterActorsByPredicate(Actors, FilteredActors, Inclusivity, [&](AActor* Actor) -> bool + { + const USceneComponent* Root = Actor->GetRootComponent(); + return Root && Root->Mobility == Mobility; + }); + + UE_LOG(LogDirectiveUtilEditor, Display, TEXT("Actor Filter: Found %i actors that %s mobility of %s"), + FilteredActors.Num(), Inclusivity == EDirectiveUtilInclusivity::Include ? TEXT("has") : TEXT("does not have"), + *UEnum::GetValueAsString(Mobility)); +} + +void UDirectiveUtilEditorActorSubsystem::FilterActorsByCollisionChannel( + const TArray& Actors, + TArray& FilteredActors, + const ECollisionChannel CollisionChannel, + const EDirectiveUtilInclusivity Inclusivity) +{ + FilterActorsByPredicate(Actors, FilteredActors, Inclusivity, [&](AActor* Actor) -> bool + { + TArray StaticMeshComponents; + Actor->GetComponents(StaticMeshComponents, true); + for (const UStaticMeshComponent* StaticMeshComponent : StaticMeshComponents) + { + if (!StaticMeshComponent) { continue; } + if (StaticMeshComponent->GetCollisionObjectType() == CollisionChannel) { return true; } + } + return false; + }); + + UE_LOG(LogDirectiveUtilEditor, Display, TEXT("Actor Filter: Found %i actors that %s collision channel of %s"), + FilteredActors.Num(), Inclusivity == EDirectiveUtilInclusivity::Include ? TEXT("has") : TEXT("does not have"), + *UEnum::GetValueAsString(CollisionChannel)); +} + +void UDirectiveUtilEditorActorSubsystem::FilterActorsByCollisionResponse( + const TArray& Actors, + TArray& FilteredActors, + const ECollisionChannel CollisionChannel, + const ECollisionResponse CollisionResponse, + const EDirectiveUtilInclusivity Inclusivity) +{ + FilterActorsByPredicate(Actors, FilteredActors, Inclusivity, [&](AActor* Actor) -> bool + { + TArray StaticMeshComponents; + Actor->GetComponents(StaticMeshComponents, true); + for (const UStaticMeshComponent* StaticMeshComponent : StaticMeshComponents) + { + if (!StaticMeshComponent) { continue; } + if (StaticMeshComponent->GetCollisionResponseToChannel(CollisionChannel) == CollisionResponse) { return true; } + } + return false; + }); + + UE_LOG(LogDirectiveUtilEditor, Display, TEXT("Actor Filter: Found %i actors that %s collision response of %s"), + FilteredActors.Num(), Inclusivity == EDirectiveUtilInclusivity::Include ? TEXT("has") : TEXT("does not have"), + *UEnum::GetValueAsString(CollisionResponse)); +} + +void UDirectiveUtilEditorActorSubsystem::FilterActorsByCollisionEnabled( + const TArray& Actors, + TArray& FilteredActors, + const ECollisionEnabled::Type CollisionEnabled, + const EDirectiveUtilInclusivity Inclusivity) +{ + FilterActorsByPredicate(Actors, FilteredActors, Inclusivity, [&](AActor* Actor) -> bool + { + TArray StaticMeshComponents; + Actor->GetComponents(StaticMeshComponents, true); + for (const UStaticMeshComponent* StaticMeshComponent : StaticMeshComponents) + { + if (!StaticMeshComponent) { continue; } + if (StaticMeshComponent->GetCollisionEnabled() == CollisionEnabled) { return true; } + } + return false; + }); + + UE_LOG(LogDirectiveUtilEditor, Display, TEXT("Actor Filter: Found %i actors that %s collision enabled of %s"), + FilteredActors.Num(), Inclusivity == EDirectiveUtilInclusivity::Include ? TEXT("has") : TEXT("does not have"), + *UEnum::GetValueAsString(CollisionEnabled)); +} + +void UDirectiveUtilEditorActorSubsystem::FilterActorsByCollisionProfile( + const TArray& Actors, + TArray& FilteredActors, + const FName CollisionProfile, + const EDirectiveUtilInclusivity Inclusivity) +{ + FilterActorsByPredicate(Actors, FilteredActors, Inclusivity, [&](AActor* Actor) -> bool + { + TArray StaticMeshComponents; + Actor->GetComponents(StaticMeshComponents, true); + for (const UStaticMeshComponent* StaticMeshComponent : StaticMeshComponents) + { + if (!StaticMeshComponent) { continue; } + if (StaticMeshComponent->GetCollisionProfileName() == CollisionProfile) { return true; } + } + return false; + }); + + UE_LOG(LogDirectiveUtilEditor, Display, TEXT("Actor Filter: Found %i actors that %s collision profile of %s"), + FilteredActors.Num(), Inclusivity == EDirectiveUtilInclusivity::Include ? TEXT("has") : TEXT("does not have"), + *CollisionProfile.ToString()); +} + +void UDirectiveUtilEditorActorSubsystem::FilterActorsByTextureName( + const TArray& Actors, + TArray& FilteredActors, + const FString TextureName, + const EDirectiveUtilSearchLocation Source, + const EDirectiveUtilInclusivity Inclusivity) +{ + FilterActorsByPredicate(Actors, FilteredActors, Inclusivity, [&](AActor* Actor) -> bool + { + TArray StaticMeshComponents; + Actor->GetComponents(StaticMeshComponents, true); + for (const UStaticMeshComponent* StaticMeshComponent : StaticMeshComponents) + { + if (!StaticMeshComponent) { continue; } + + if (Source == BaseAndOverride || Source == OverrideOnly) + { + for (UMaterialInterface* Material : StaticMeshComponent->GetMaterials()) + { + if (!Material || !Material->GetMaterial()) { continue; } + for (const auto& Expression : Material->GetMaterial()->GetExpressions()) + { + if (const UMaterialExpressionTextureSample* TextureSample = Cast(Expression)) + { + if (TextureSample->Texture.GetName().Contains(TextureName)) { return true; } + } + if (const UMaterialExpressionTextureObject* TextureObject = Cast(Expression)) + { + if (TextureObject->Texture.GetName().Contains(TextureName)) { return true; } + } + } + } + } + + if (Source == BaseAndOverride || Source == BaseOnly) + { + const UStaticMesh* Mesh = StaticMeshComponent->GetStaticMesh(); + if (!Mesh) { continue; } + for (int32 i = 0; i < Mesh->GetStaticMaterials().Num(); i++) + { + UMaterialInterface* Material = Mesh->GetMaterial(i); + if (!Material || !Material->GetMaterial()) { continue; } + for (const auto& Expression : Material->GetMaterial()->GetExpressions()) + { + if (const UMaterialExpressionTextureSample* TextureSample = Cast(Expression)) + { + if (TextureSample->Texture.GetName().Contains(TextureName)) { return true; } + } + if (const UMaterialExpressionTextureObject* TextureObject = Cast(Expression)) + { + if (TextureObject->Texture.GetName().Contains(TextureName)) { return true; } + } + } + } + } + } + return false; + }); + + UE_LOG(LogDirectiveUtilEditor, Display, TEXT("Actor Filter: Found %i actors that %s texture name of %s"), + FilteredActors.Num(), Inclusivity == EDirectiveUtilInclusivity::Include ? TEXT("has") : TEXT("does not have"), + *TextureName); +} + +void UDirectiveUtilEditorActorSubsystem::FilterActorsByTexture( + const TArray& Actors, + TArray& FilteredActors, + TSoftObjectPtr TextureReference, + const EDirectiveUtilSearchLocation Source, + const EDirectiveUtilInclusivity Inclusivity) +{ + FilterActorsByPredicate(Actors, FilteredActors, Inclusivity, [&](AActor* Actor) -> bool + { + TArray StaticMeshComponents; + Actor->GetComponents(StaticMeshComponents, true); + for (const UStaticMeshComponent* StaticMeshComponent : StaticMeshComponents) + { + if (!StaticMeshComponent) { continue; } + + if (Source == BaseAndOverride || Source == OverrideOnly) + { + for (UMaterialInterface* Material : StaticMeshComponent->GetMaterials()) + { + if (!Material || !Material->GetMaterial()) { continue; } + for (const auto& Expression : Material->GetMaterial()->GetExpressions()) + { + if (const UMaterialExpressionTextureSample* TextureSample = Cast(Expression)) + { + if (TextureSample->Texture == TextureReference) { return true; } + } + if (const UMaterialExpressionTextureObject* TextureObject = Cast(Expression)) + { + if (TextureObject->Texture == TextureReference) { return true; } + } + } + } + } + + if (Source == BaseAndOverride || Source == BaseOnly) + { + const UStaticMesh* Mesh = StaticMeshComponent->GetStaticMesh(); + if (!Mesh) { continue; } + for (int32 i = 0; i < Mesh->GetStaticMaterials().Num(); i++) + { + UMaterialInterface* Material = Mesh->GetMaterial(i); + if (!Material || !Material->GetMaterial()) { continue; } + for (const auto& Expression : Material->GetMaterial()->GetExpressions()) + { + if (const UMaterialExpressionTextureSample* TextureSample = Cast(Expression)) + { + if (TextureSample->Texture == TextureReference) { return true; } + } + if (const UMaterialExpressionTextureObject* TextureObject = Cast(Expression)) + { + if (TextureObject->Texture == TextureReference) { return true; } + } + } + } + } + } + return false; + }); +} + +void UDirectiveUtilEditorActorSubsystem::FilterEmptyActors( + const TArray& Actors, + TArray& FilteredActors, + const EDirectiveUtilInclusivity Inclusivity) +{ + FilterActorsByPredicate(Actors, FilteredActors, Inclusivity, [](AActor* Actor) -> bool + { + TArray ActorComponents; + Actor->GetComponents(ActorComponents); + if (ActorComponents.IsEmpty()) { return true; } + if (ActorComponents.Num() == 1) + { + // ExactCast: a subclass component (mesh, light, camera...) means the actor does something. + const USceneComponent* SceneComponent = ExactCast(ActorComponents[0]); + return SceneComponent && SceneComponent->GetNumChildrenComponents() == 0; + } + return false; + }); + + UE_LOG(LogDirectiveUtilEditor, Display, TEXT("Actor Filter: Found %i actors that %s empty"), + FilteredActors.Num(), Inclusivity == EDirectiveUtilInclusivity::Include ? TEXT("are") : TEXT("are not")); +} + +void UDirectiveUtilEditorActorSubsystem::FilterActorsByMissingMaterials( + const TArray& Actors, + TArray& FilteredActors, + const EDirectiveUtilSearchLocation Location, + const EDirectiveUtilInclusivity Inclusivity) +{ + FilterActorsByMaterial(Actors, FilteredActors, nullptr, Location, Inclusivity); +} + +void UDirectiveUtilEditorActorSubsystem::FilterActorsByMissingStaticMeshes( + const TArray& Actors, + TArray& FilteredActors, + const EDirectiveUtilInclusivity Inclusivity) +{ + FilterActorsByStaticMesh(Actors, FilteredActors, nullptr, Inclusivity); +} + +void UDirectiveUtilEditorActorSubsystem::FilterActorsByMissingTextures( + const TArray& Actors, + TArray& FilteredActors, + const EDirectiveUtilSearchLocation Location, + const EDirectiveUtilInclusivity Inclusivity) +{ + FilterActorsByTexture(Actors, FilteredActors, nullptr, Location, Inclusivity); +} + +bool UDirectiveUtilEditorActorSubsystem::IsActorWithinBoxBounds(AActor* Actor, UBoxComponent* BoxComponent) +{ + if (!Actor || !BoxComponent) + { + return false; + } + + // Transform the actor's location into the box's local space so the box's rotation and its own + // scale are respected, then test against the unscaled extent (a proper oriented-box check). + const FVector LocalLocation = BoxComponent->GetComponentTransform().InverseTransformPosition(Actor->GetActorLocation()); + const FVector Extent = BoxComponent->GetUnscaledBoxExtent(); + return FMath::Abs(LocalLocation.X) <= Extent.X + && FMath::Abs(LocalLocation.Y) <= Extent.Y + && FMath::Abs(LocalLocation.Z) <= Extent.Z; +} + +bool UDirectiveUtilEditorActorSubsystem::IsActorWithinSphereBounds(AActor* Actor, USphereComponent* SphereComponent) +{ + if (!Actor || !SphereComponent) + { + return false; + } + const float DeltaLoc = FVector::Dist(Actor->GetActorLocation(), SphereComponent->GetComponentLocation()); + return DeltaLoc <= SphereComponent->GetScaledSphereRadius(); +} + +bool UDirectiveUtilEditorActorSubsystem::IsActorWithinCapsuleBounds(AActor* Actor, UCapsuleComponent* CapsuleComponent) +{ + if (!Actor || !CapsuleComponent) + { + return false; + } + // Proper capsule containment: distance from the actor to the capsule's central segment (its local + // Z axis, between the cap centres) must be within the radius. Local space respects orientation. + const FVector LocalLocation = CapsuleComponent->GetComponentTransform().InverseTransformPosition(Actor->GetActorLocation()); + const float Radius = CapsuleComponent->GetUnscaledCapsuleRadius(); + const float SegmentHalfHeight = FMath::Max(0.0f, CapsuleComponent->GetUnscaledCapsuleHalfHeight() - Radius); + const FVector ClosestOnSegment(0.0f, 0.0f, FMath::Clamp(LocalLocation.Z, -SegmentHalfHeight, SegmentHalfHeight)); + return FVector::Dist(LocalLocation, ClosestOnSegment) <= Radius; +} + +void UDirectiveUtilEditorActorSubsystem::GetActorsByClass( + TArray& FoundActors, + const TSubclassOf ActorClass, + const EDirectiveUtilSelectionMethod SelectionMethod, + const EDirectiveUtilInclusivity Inclusivity) +{ + const TArray ActorsToFilter = SelectionMethod == Selection ? GetSelectedLevelActors() : GetAllLevelActors(); + FilterActorsByClass(ActorsToFilter, FoundActors, ActorClass, Inclusivity); +} + +void UDirectiveUtilEditorActorSubsystem::GetActorsByName( + TArray& FoundActors, + const FString ActorName, + const EDirectiveUtilSelectionMethod SelectionMethod, + const EDirectiveUtilInclusivity Inclusivity) +{ + const TArray ActorsToFilter = SelectionMethod == Selection ? GetSelectedLevelActors() : GetAllLevelActors(); + FilterActorsByName(ActorsToFilter, FoundActors, ActorName, Inclusivity); +} + +void UDirectiveUtilEditorActorSubsystem::GetActorsByMaterial( + TArray& FoundActors, + const UMaterialInterface* Material, + const EDirectiveUtilSearchLocation MaterialSource, + const EDirectiveUtilSelectionMethod SelectionMethod, + const EDirectiveUtilInclusivity Inclusivity) +{ + const TSoftObjectPtr MaterialSoftReference{FSoftObjectPath(Material)}; + GetActorsByMaterialSoftReference(FoundActors, MaterialSoftReference, MaterialSource, SelectionMethod, Inclusivity); +} + + +void UDirectiveUtilEditorActorSubsystem::GetActorsByMaterialSoftReference( + TArray& FoundActors, + const TSoftObjectPtr Material, + const EDirectiveUtilSearchLocation MaterialSource, + const EDirectiveUtilSelectionMethod SelectionMethod, + const EDirectiveUtilInclusivity Inclusivity) +{ + const TArray SourceActors = SelectionMethod == Selection ? GetSelectedLevelActors() : GetAllLevelActors(); + + TArray StaticMeshActors; + FilterStaticMeshActors(StaticMeshActors, SourceActors); + + FilterActorsByPredicate(StaticMeshActors, FoundActors, Inclusivity, [&](AStaticMeshActor* StaticMeshActor) -> bool + { + const UStaticMeshComponent* StaticMeshComp = StaticMeshActor->GetStaticMeshComponent(); + if (!StaticMeshComp) { return false; } + const UStaticMesh* Mesh = StaticMeshComp->GetStaticMesh(); + if (!Mesh) { return false; } + + if (MaterialSource == BaseAndOverride || MaterialSource == OverrideOnly) + { + for (int32 i = 0; i < StaticMeshComp->GetNumMaterials(); i++) + { + if (StaticMeshComp->GetMaterial(i) == Material) { return true; } + } + } + if (MaterialSource == BaseAndOverride || MaterialSource == BaseOnly) + { + for (int32 i = 0; i < Mesh->GetStaticMaterials().Num(); i++) + { + if (Mesh->GetMaterial(i) == Material) { return true; } + } + } + return false; + }); + + UE_LOG(LogDirectiveUtilEditor, Display, TEXT("%i actors with the material reference were found."), + FoundActors.Num()); +} + +void UDirectiveUtilEditorActorSubsystem::GetActorsByMaterialName( + TArray& FoundActors, + const FString MaterialName, + const EDirectiveUtilSearchLocation MaterialSource, + const EDirectiveUtilSelectionMethod SelectionMethod, + const EDirectiveUtilInclusivity Inclusivity) +{ + const TArray SourceActors = SelectionMethod == Selection ? GetSelectedLevelActors() : GetAllLevelActors(); + + TArray StaticMeshActors; + FilterStaticMeshActors(StaticMeshActors, SourceActors); + + FilterActorsByPredicate(StaticMeshActors, FoundActors, Inclusivity, [&](AStaticMeshActor* StaticMeshActor) -> bool + { + const UStaticMeshComponent* StaticMeshComp = StaticMeshActor->GetStaticMeshComponent(); + if (!StaticMeshComp) { return false; } + const UStaticMesh* Mesh = StaticMeshComp->GetStaticMesh(); + if (!Mesh) { return false; } + + if (MaterialSource == BaseAndOverride || MaterialSource == OverrideOnly) + { + for (int32 i = 0; i < StaticMeshComp->GetNumMaterials(); i++) + { + const UMaterialInterface* Mat = StaticMeshComp->GetMaterial(i); + if (Mat && Mat->GetName().Contains(MaterialName)) { return true; } + } + } + if (MaterialSource == BaseAndOverride || MaterialSource == BaseOnly) + { + for (int32 i = 0; i < Mesh->GetStaticMaterials().Num(); i++) + { + const UMaterialInterface* Mat = Mesh->GetMaterial(i); + if (Mat && Mat->GetName().Contains(MaterialName)) { return true; } + } + } + return false; + }); + + UE_LOG(LogDirectiveUtilEditor, Display, TEXT("%i actors with material %s were found."), FoundActors.Num(), + *MaterialName); +} + +void UDirectiveUtilEditorActorSubsystem::GetActorsByVertexCount( + TArray& FoundActors, + const int32 From, + const int32 To, + const EDirectiveUtilSelectionMethod SelectionMethod, + const EDirectiveUtilInclusivity Inclusivity) +{ + const TArray SourceActors = SelectionMethod == Selection ? GetSelectedLevelActors() : GetAllLevelActors(); + FilterActorsByPredicate(SourceActors, FoundActors, Inclusivity, [&](AActor* Actor) -> bool + { + TArray StaticMeshComponents; + Actor->GetComponents(StaticMeshComponents, true); + for (const UStaticMeshComponent* StaticMeshComponent : StaticMeshComponents) + { + if (!StaticMeshComponent) { continue; } + const UStaticMesh* StaticMesh = StaticMeshComponent->GetStaticMesh(); + if (!StaticMesh || !StaticMesh->HasValidRenderData(true, 0)) { continue; } + const int32 VertexCount = StaticMesh->GetNumVertices(0); + if (VertexCount >= From && VertexCount <= To) { return true; } + } + return false; + }); + + UE_LOG(LogDirectiveUtilEditor, Display, TEXT("%i actors with vertex count between %i and %i were found."), + FoundActors.Num(), From, To); +} + +void UDirectiveUtilEditorActorSubsystem::GetActorsByTriCount( + TArray& FoundActors, + const int32 From, + const int32 To, + const EDirectiveUtilSelectionMethod SelectionMethod, + const EDirectiveUtilInclusivity Inclusivity) +{ + const TArray SourceActors = SelectionMethod == Selection ? GetSelectedLevelActors() : GetAllLevelActors(); + FilterActorsByPredicate(SourceActors, FoundActors, Inclusivity, [&](AActor* Actor) -> bool + { + TArray StaticMeshComponents; + Actor->GetComponents(StaticMeshComponents, true); + for (const UStaticMeshComponent* StaticMeshComponent : StaticMeshComponents) + { + if (!StaticMeshComponent) { continue; } + const UStaticMesh* StaticMesh = StaticMeshComponent->GetStaticMesh(); + if (!StaticMesh || !StaticMesh->HasValidRenderData(true, 0)) { continue; } + const int32 TriCount = StaticMesh->GetNumTriangles(0); + if (TriCount >= From && TriCount <= To) { return true; } + } + return false; + }); + + UE_LOG(LogDirectiveUtilEditor, + Display, + TEXT("%i actors with triangle count between %i and %i were found."), + FoundActors.Num(), + From, + To); +} + +void UDirectiveUtilEditorActorSubsystem::GetActorsByBoundingBox( + TArray& FoundActors, + const FVector Min, + const FVector Max, + const EDirectiveUtilSelectionMethod SelectionMethod, + const EDirectiveUtilInclusivity Inclusivity) +{ + const TArray SourceActors = SelectionMethod == Selection ? GetSelectedLevelActors() : GetAllLevelActors(); + FilterActorsByPredicate(SourceActors, FoundActors, Inclusivity, [&](AActor* Actor) -> bool + { + const FBox QueryBox(Min, Max); + const FBox Box = Actor->GetComponentsBoundingBox(); + return Box.IsValid && QueryBox.IsInsideOrOn(Box.Min) && QueryBox.IsInsideOrOn(Box.Max); + }); + + + UE_LOG(LogDirectiveUtilEditor, Display, TEXT("%i actors with bounding box between %s and %s were found."), + FoundActors.Num(), *Min.ToString(), *Max.ToString()); +} + +void UDirectiveUtilEditorActorSubsystem::GetActorsByMeshSize( + TArray& FoundActors, + const float From, + const float To, + const EDirectiveUtilSelectionMethod SelectionMethod, + const EDirectiveUtilInclusivity Inclusivity) +{ + const TArray SourceActors = SelectionMethod == Selection ? GetSelectedLevelActors() : GetAllLevelActors(); + FilterActorsByPredicate(SourceActors, FoundActors, Inclusivity, [&](AActor* Actor) -> bool + { + TArray StaticMeshComponents; + Actor->GetComponents(StaticMeshComponents, true); + for (const UStaticMeshComponent* StaticMeshComponent : StaticMeshComponents) + { + if (!StaticMeshComponent || !StaticMeshComponent->GetStaticMesh()) { continue; } + const double BoundBoxSize = StaticMeshComponent->GetStaticMesh()->GetBoundingBox().GetSize().Size(); + if (BoundBoxSize >= From && BoundBoxSize <= To) { return true; } + } + return false; + }); + + UE_LOG(LogDirectiveUtilEditor, Display, TEXT("%i actors with mesh size between %f and %f were found."), + FoundActors.Num(), From, To); +} + +void UDirectiveUtilEditorActorSubsystem::GetActorsByWorldLocation( + TArray& FoundActors, + const FVector WorldLocation, + const float Radius, + const EDirectiveUtilSelectionMethod SelectionMethod, + const EDirectiveUtilInclusivity Inclusivity) +{ + const TArray SourceActors = SelectionMethod == Selection ? GetSelectedLevelActors() : GetAllLevelActors(); + FilterActorsByPredicate(SourceActors, FoundActors, Inclusivity, [&](AActor* Actor) -> bool + { + return FVector::Dist(Actor->GetActorLocation(), WorldLocation) <= Radius; + }); + + UE_LOG(LogDirectiveUtilEditor, Display, TEXT("%i actors with world location %s and radius %f were found."), + FoundActors.Num(), *WorldLocation.ToString(), Radius); +} + +void UDirectiveUtilEditorActorSubsystem::GetActorsByLODCount( + TArray& FoundActors, + const int32 LODCountFrom, + const int32 LODCountTo, + const EDirectiveUtilSelectionMethod SelectionMethod, + const EDirectiveUtilInclusivity Inclusivity) +{ + const TArray SourceActors = SelectionMethod == Selection ? GetSelectedLevelActors() : GetAllLevelActors(); + FilterActorsByPredicate(SourceActors, FoundActors, Inclusivity, [&](AActor* Actor) -> bool + { + TArray StaticMeshComponents; + Actor->GetComponents(StaticMeshComponents, true); + for (const UStaticMeshComponent* StaticMeshComponent : StaticMeshComponents) + { + if (!StaticMeshComponent) { continue; } + const UStaticMesh* StaticMesh = StaticMeshComponent->GetStaticMesh(); + if (!StaticMesh) { continue; } + if (StaticMesh->GetNumLODs() >= LODCountFrom && StaticMesh->GetNumLODs() <= LODCountTo) { return true; } + } + return false; + }); + + UE_LOG(LogDirectiveUtilEditor, Display, TEXT("%i actors with LOD count between %i and %i were found."), + FoundActors.Num(), LODCountFrom, LODCountTo); +} + +void UDirectiveUtilEditorActorSubsystem::GetActorsByNaniteEnabled( + TArray& FoundActors, + const bool bNaniteEnabled, + const EDirectiveUtilSelectionMethod SelectionMethod, + const EDirectiveUtilInclusivity Inclusivity) +{ + const TArray SourceActors = SelectionMethod == Selection ? GetSelectedLevelActors() : GetAllLevelActors(); + FilterActorsByPredicate(SourceActors, FoundActors, Inclusivity, [&](AActor* Actor) -> bool + { + TArray StaticMeshComponents; + Actor->GetComponents(StaticMeshComponents, true); + for (const UStaticMeshComponent* StaticMeshComponent : StaticMeshComponents) + { + if (!StaticMeshComponent) { continue; } + const UStaticMesh* Mesh = StaticMeshComponent->GetStaticMesh(); + if (!Mesh) { continue; } +#if ENGINE_MAJOR_VERSION == 5 && ENGINE_MINOR_VERSION >= 7 + const bool bMeshNaniteEnabled = Mesh->GetNaniteSettings().bEnabled; +#else + const bool bMeshNaniteEnabled = Mesh->NaniteSettings.bEnabled; +#endif + if (bMeshNaniteEnabled == bNaniteEnabled) { return true; } + } + return false; + }); + + UE_LOG(LogDirectiveUtilEditor, Display, TEXT("%i actors with nanite enabled %s were found."), + FoundActors.Num(), bNaniteEnabled ? TEXT("true") : TEXT("false")); +} + +void UDirectiveUtilEditorActorSubsystem::GetActorsByLightmapResolution( + TArray& FoundActors, + const int32 From, + const int32 To, + const EDirectiveUtilSelectionMethod SelectionMethod, + const EDirectiveUtilInclusivity Inclusivity) +{ + const TArray SourceActors = SelectionMethod == Selection ? GetSelectedLevelActors() : GetAllLevelActors(); + FilterActorsByPredicate(SourceActors, FoundActors, Inclusivity, [&](AActor* Actor) -> bool + { + TArray StaticMeshComponents; + Actor->GetComponents(StaticMeshComponents, true); + for (const UStaticMeshComponent* StaticMeshComponent : StaticMeshComponents) + { + if (!StaticMeshComponent || !StaticMeshComponent->GetStaticMesh()) { continue; } + const int32 LightmapRes = StaticMeshComponent->GetStaticMesh()->GetLightMapResolution(); + if (LightmapRes >= From && LightmapRes <= To) { return true; } + } + return false; + }); + + UE_LOG( + LogDirectiveUtilEditor, + Display, + TEXT("%i actors with lightmap resolution between %i and %i were found."), + FoundActors.Num(), + From, + To); +} + +void UDirectiveUtilEditorActorSubsystem::GetActorsByMobility( + TArray& FoundActors, + const EComponentMobility::Type Mobility, + const EDirectiveUtilSelectionMethod SelectionMethod, + const EDirectiveUtilInclusivity Inclusivity) +{ + const TArray SourceActors = SelectionMethod == Selection ? GetSelectedLevelActors() : GetAllLevelActors(); + FilterActorsByPredicate(SourceActors, FoundActors, Inclusivity, [&](AActor* Actor) -> bool + { + const USceneComponent* Root = Actor->GetRootComponent(); + return Root && Root->Mobility == Mobility; + }); + + UE_LOG(LogDirectiveUtilEditor, Display, TEXT("%i actors with mobility %s were found."), + FoundActors.Num(), *UEnum::GetValueAsName(Mobility).ToString()); +} + +void UDirectiveUtilEditorActorSubsystem::GetActorsByStaticMesh( + TArray& FoundActors, + UStaticMesh* StaticMesh, + const EDirectiveUtilSelectionMethod SelectionMethod, + const EDirectiveUtilInclusivity Inclusivity) +{ + GetActorsByStaticMeshSoftReference(FoundActors, StaticMesh, SelectionMethod, Inclusivity); +} + +void UDirectiveUtilEditorActorSubsystem::GetActorsByStaticMeshSoftReference( + TArray& FoundActors, + TSoftObjectPtr StaticMesh, + const EDirectiveUtilSelectionMethod SelectionMethod, + const EDirectiveUtilInclusivity Inclusivity) +{ + const TArray SourceActors = SelectionMethod == Selection ? GetSelectedLevelActors() : GetAllLevelActors(); + + FilterActorsByPredicate(SourceActors, FoundActors, Inclusivity, [&](AActor* Actor) -> bool + { + for (const auto Component : Actor->GetComponents()) + { + const UStaticMeshComponent* StaticMeshComponent = Cast(Component); + if (!StaticMeshComponent) { continue; } + if (StaticMeshComponent->GetStaticMesh() == StaticMesh) { return true; } + } + return false; + }); + UE_LOG(LogDirectiveUtilEditor, Display, TEXT("%i actors with static mesh %s were found."), + FoundActors.Num(), *StaticMesh.ToString()); +} + +void UDirectiveUtilEditorActorSubsystem::GetActorsByStaticMeshName( + TArray& FoundActors, + const FString StaticMeshName, + const EDirectiveUtilSelectionMethod SelectionMethod, + const EDirectiveUtilInclusivity Inclusivity) +{ + const TArray SourceActors = SelectionMethod == Selection ? GetSelectedLevelActors() : GetAllLevelActors(); + + FilterActorsByPredicate(SourceActors, FoundActors, Inclusivity, [&](AActor* Actor) -> bool + { + for (const auto Component : Actor->GetComponents()) + { + const UStaticMeshComponent* StaticMeshComponent = Cast(Component); + if (!StaticMeshComponent) { continue; } + const UStaticMesh* Mesh = StaticMeshComponent->GetStaticMesh(); + if (!Mesh) + { + // A null static mesh only counts as a match when filtering for an empty name. + if (StaticMeshName.IsEmpty()) { return true; } + continue; + } + if (Mesh->GetName().Contains(StaticMeshName)) { return true; } + } + return false; + }); + UE_LOG(LogDirectiveUtilEditor, Display, TEXT("%i actors with static mesh %s were found."), + FoundActors.Num(), *StaticMeshName); +} + +void UDirectiveUtilEditorActorSubsystem::GetActorsByTexture( + TArray& FoundActors, + UTexture2D* Texture, + const EDirectiveUtilSelectionMethod SelectionMethod, + const EDirectiveUtilInclusivity Inclusivity) +{ + GetActorsByTextureSoftReference(FoundActors, Texture, SelectionMethod, Inclusivity); +} + +void UDirectiveUtilEditorActorSubsystem::GetActorsByTextureSoftReference( + TArray& FoundActors, + const TSoftObjectPtr Texture, + const EDirectiveUtilSelectionMethod SelectionMethod, + const EDirectiveUtilInclusivity Inclusivity) +{ + const TArray SourceActors = SelectionMethod == Selection ? GetSelectedLevelActors() : GetAllLevelActors(); + + FilterActorsByPredicate(SourceActors, FoundActors, Inclusivity, [&](AActor* Actor) -> bool + { + for (const auto Component : Actor->GetComponents()) + { + const UStaticMeshComponent* StaticMeshComponent = Cast(Component); + if (!StaticMeshComponent) { continue; } + for (UMaterialInterface* Material : StaticMeshComponent->GetMaterials()) + { + if (!Material || !Material->GetMaterial()) { continue; } + for (const auto& Expression : Material->GetMaterial()->GetExpressions()) + { + if (const UMaterialExpressionTextureSample* TextureSample = Cast(Expression)) + { + if (TextureSample->Texture == Texture) { return true; } + } + if (const UMaterialExpressionTextureObject* TextureObject = Cast(Expression)) + { + if (TextureObject->Texture == Texture) { return true; } + } + } + } + } + return false; + }); + UE_LOG(LogDirectiveUtilEditor, Display, TEXT("%i actors with texture %s were found."), + FoundActors.Num(), *Texture.ToString()); +} + +void UDirectiveUtilEditorActorSubsystem::GetActorsByTextureName( + TArray& FoundActors, + const FString TextureName, + const EDirectiveUtilSelectionMethod SelectionMethod, + const EDirectiveUtilInclusivity Inclusivity) +{ + const TArray SourceActors = SelectionMethod == Selection ? GetSelectedLevelActors() : GetAllLevelActors(); + + FilterActorsByPredicate(SourceActors, FoundActors, Inclusivity, [&](AActor* Actor) -> bool + { + for (const auto Component : Actor->GetComponents()) + { + const UStaticMeshComponent* StaticMeshComponent = Cast(Component); + if (!StaticMeshComponent) { continue; } + for (UMaterialInterface* Material : StaticMeshComponent->GetMaterials()) + { + if (!Material || !Material->GetMaterial()) { continue; } + for (const auto& Expression : Material->GetMaterial()->GetExpressions()) + { + if (const UMaterialExpressionTextureSample* TextureSample = Cast(Expression)) + { + if (TextureSample->Texture.GetName().Contains(TextureName)) { return true; } + } + if (const UMaterialExpressionTextureObject* TextureObject = Cast(Expression)) + { + if (TextureObject->Texture.GetName().Contains(TextureName)) { return true; } + } + } + } + } + return false; + }); + UE_LOG(LogDirectiveUtilEditor, Display, TEXT("%i actors with texture %s were found."), + FoundActors.Num(), *TextureName); +} + +void UDirectiveUtilEditorActorSubsystem::GetInvalidActors(TArray& FoundActors) +{ + for (AActor* Actor : GetAllLevelActors()) { if (!IsValid(Actor)) { FoundActors.AddUnique(Actor); } } + UE_LOG(LogDirectiveUtilEditor, Display, TEXT("%i invalid actors were found."), FoundActors.Num()); +} + +void UDirectiveUtilEditorActorSubsystem::PushOverrideMaterialsToSource(UStaticMeshComponent* StaticMeshComponent) +{ + if (!IsValid(StaticMeshComponent)) + { + UE_LOG(LogDirectiveUtilEditor, Error, TEXT("Static Mesh Component is invalid.")); + return; + } + + UStaticMesh* StaticMesh = StaticMeshComponent->GetStaticMesh(); + if (!IsValid(StaticMesh)) + { + UE_LOG(LogDirectiveUtilEditor, Error, TEXT("Static Mesh Component has no valid static mesh.")); + return; + } + + const FScopedTransaction Transaction(NSLOCTEXT("DirectiveUtilities", "PushOverrideMaterialsToSource", "Push Override Materials To Source")); + for (int32 i = 0; i < StaticMeshComponent->GetNumMaterials(); i++) + { + if (UMaterialInterface* Material = StaticMeshComponent->GetMaterial(i)) + { + StaticMesh->SetMaterial(i, Material); + } + } + UE_LOG(LogDirectiveUtilEditor, Display, TEXT("Materials were pushed to source for %s."), *StaticMeshComponent->GetName()); +} diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Public/DirectiveUtilitiesEditor.h b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Public/DirectiveUtilitiesEditor.h new file mode 100644 index 00000000..a21d5bc3 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Public/DirectiveUtilitiesEditor.h @@ -0,0 +1,13 @@ +// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License. + +#pragma once + +#include "CoreMinimal.h" +#include "Modules/ModuleManager.h" + +class FDirectiveUtilitiesEditorModule : public IModuleInterface +{ +public: + virtual void StartupModule() override; + virtual void ShutdownModule() override; +}; diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Public/Libraries/DirectiveUtilEditorAssetLibrary.h b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Public/Libraries/DirectiveUtilEditorAssetLibrary.h new file mode 100644 index 00000000..d9b14738 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Public/Libraries/DirectiveUtilEditorAssetLibrary.h @@ -0,0 +1,102 @@ +// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License. + +#pragma once + +#include "CoreMinimal.h" +#include "EditorAssetLibrary.h" +#include "Types/DirectiveUtilEditorAssetTypes.h" +#include "Types/DirectiveUtilTypes.h" +#include "DirectiveUtilEditorAssetLibrary.generated.h" + +/** + * UDirectiveUtilEditorAssetLibrary + * + * Blueprint helpers for querying and managing editor assets. + */ +UCLASS() +class DIRECTIVEUTILITIESEDITOR_API UDirectiveUtilEditorAssetLibrary : public UEditorAssetLibrary +{ + GENERATED_BODY() + +public: + + /** + * Retrieve a list of asset data for the given directory. + * @param DirectoryPath Directory path of the asset we want the list from. (e.g., /Game/MyFolder or /MyPluginName/MyFolder) + * @param bRecursive The search will be recursive and will look in subfolders. Defaults to true. + * @return TArray List of asset data. + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities | Editor Scripting | Asset") + static TArray GetAssetDataListFromDirectory(const FString& DirectoryPath, bool bRecursive = true); + + /** + * Find and return a list of duplicate assets within the given directories. + * The criteria for duplication is based on the asset name and class. + * @param DirectoryPaths List of directory paths to search for duplicate assets. + * (e.g., /Game/MyFolder or /MyPluginName/MyFolder) + * @param bRecursive The search will be recursive and will look in subfolders. Defaults to true. + * @return TMap Mapped list of duplicate assets. + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities | Editor Scripting | Asset") + static TMap FindDuplicateAssets( + const TArray& DirectoryPaths, + bool bRecursive = true); + + /** + * Fixes up (and deletes) object redirectors found under the given directories, without loading every asset. + * Equivalent to the Content Browser's "Fix Up Redirectors in Folder", but scriptable and headless-friendly. + * @param DirectoryPaths Directories to scan for redirectors. If empty, the entire registry is scanned. + * @param OutRedirectorsProcessed [out] The number of redirectors submitted for fix-up (the engine does not report per-redirector success). + * @return Success if the operation ran (even if nothing needed fixing), Failure otherwise (e.g. a fixup is already in progress). + */ + UFUNCTION(BlueprintCallable, meta = (ExpandEnumAsExecs = "ReturnValue"), Category = "Directive Utilities | Editor Scripting | Asset") + static EDirectiveUtilSuccessStatus FixUpRedirectorsInPaths(const TArray& DirectoryPaths, int32& OutRedirectorsProcessed); + + /** + * Finds all assets of the given class using the Asset Registry (no asset loading). + * @param AssetClass The class to search for. + * @param PackagePath An optional package path to scope the search (e.g. /Game/MyFolder). Empty searches everywhere. + * @param bRecursiveClasses If true, also matches subclasses of AssetClass. + * @param bRecursivePaths If true, also searches subfolders of PackagePath. + * @param OutStatus [out] Success if the registry was queried successfully. + * @return The matching asset data. + */ + UFUNCTION(BlueprintCallable, meta = (ExpandEnumAsExecs = "OutStatus"), Category = "Directive Utilities | Editor Scripting | Asset") + static TArray GetAssetsByClass( + UClass* AssetClass, + const FString& PackagePath, + bool bRecursiveClasses, + bool bRecursivePaths, + EDirectiveUtilSuccessStatus& OutStatus); + + /** + * Returns the package paths of the assets that the given asset depends on, using the Asset Registry dependency graph. + * @param Asset The asset whose dependencies to retrieve. + * @param bHardDependenciesOnly If true, only hard (always-loaded) dependencies are returned. + * @param OutStatus [out] Success if the registry was queried successfully. + * @return The dependency package paths. + */ + UFUNCTION(BlueprintCallable, meta = (ExpandEnumAsExecs = "OutStatus"), Category = "Directive Utilities | Editor Scripting | Asset") + static TArray GetAssetDependencies(const FAssetData& Asset, bool bHardDependenciesOnly, EDirectiveUtilSuccessStatus& OutStatus); + + /** + * Returns the package paths of the assets that reference the given asset, using the Asset Registry dependency graph. + * @param Asset The asset whose referencers to retrieve. + * @param bHardReferencesOnly If true, only hard (always-loaded) referencers are returned. + * @param OutStatus [out] Success if the registry was queried successfully. + * @return The referencer package paths. + */ + UFUNCTION(BlueprintCallable, meta = (ExpandEnumAsExecs = "OutStatus"), Category = "Directive Utilities | Editor Scripting | Asset") + static TArray GetAssetReferencers(const FAssetData& Asset, bool bHardReferencesOnly, EDirectiveUtilSuccessStatus& OutStatus); + + /** + * Returns the default name a new asset of the given class would receive, honoring the project's asset naming + * convention overrides where the engine supports them. + * @note Naming-convention overrides are only consulted on UE 5.8+; on 5.6/5.7 the engine's plain default name is returned. + * @param AssetClass The class to resolve a default asset name for. + * @param OutStatus [out] Success if a non-empty name was resolved. + * @return The default asset name, or an empty string on failure. + */ + UFUNCTION(BlueprintCallable, meta = (ExpandEnumAsExecs = "OutStatus"), Category = "Directive Utilities | Editor Scripting | Asset") + static FString GetDefaultAssetNameForClass(UClass* AssetClass, EDirectiveUtilSuccessStatus& OutStatus); +}; diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Public/Subsystems/DirectiveUtilEditorActorSubsystem.h b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Public/Subsystems/DirectiveUtilEditorActorSubsystem.h new file mode 100644 index 00000000..0623ec4f --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Public/Subsystems/DirectiveUtilEditorActorSubsystem.h @@ -0,0 +1,695 @@ +// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License. + +#pragma once + +#include "CoreMinimal.h" +#include "Subsystems/EditorActorSubsystem.h" +#include "Engine/EngineTypes.h" +#include "Types/DirectiveUtilEditorTypes.h" +#include "DirectiveUtilEditorActorSubsystem.generated.h" + +class UCapsuleComponent; +class UBoxComponent; +class USphereComponent; +class UStaticMeshActor; + +/** + * DirectiveUtilEditorActorSubsystem + * + * Blueprint helpers for querying and filtering actors in the editor world. + */ +UCLASS() +class DIRECTIVEUTILITIESEDITOR_API UDirectiveUtilEditorActorSubsystem : public UEditorActorSubsystem +{ + GENERATED_BODY() + +public: + + //----------------------------- + // Utilities + //----------------------------- + + /** + * Focus actors in viewport. + * @param Actors The actors to focus. + * @param bInstant Enable to focus the actors instantly instead of smoothly animating. + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Editor") + static void FocusActorsInViewport(const TArray Actors, bool bInstant = false); + + /** + * Get all unique classes used in the level. + * @result Classes The list of classes. + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Editor") + TArray GetAllLevelClasses(); + + //----------------------------- + // Filters + //----------------------------- + // Output arrays are appended to and deduplicated (existing entries are preserved). + // Passing the same array as input and output filters it in place. + + /** + * Returns only the Static Mesh Actors from the provided Actor List. + * @param ActorsToFilter The list of Actors to filter. + * @param OutStaticMeshActors The list of Actors that are Static Mesh Actors. + */ + virtual void FilterStaticMeshActors(TArray& OutStaticMeshActors, TArray ActorsToFilter) const; + + /** + * Filters the provided actors based on the provided name. + * @param Actors The list of actors to filter. + * @param FilteredActors The list of actors that have been filtered. + * @param ActorName The name to filter by. + * @param Inclusivity Whether to include or exclude actors with the provided name. + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Filters|Actor") + static void FilterActorsByName(const TArray& Actors, TArray& FilteredActors, const FString& ActorName, EDirectiveUtilInclusivity Inclusivity = Include); + + /** + * Filter the provided actors based on the provided class. + * @param Actors The list of actors to filter. + * @param FilteredActors The list of actors that have been filtered. + * @param ActorClass The class to filter by. + * @param Inclusivity Whether to include or exclude actors with the provided class. + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Filters|Actor") + static void FilterActorsByClass(const TArray& Actors, TArray& FilteredActors, TSubclassOf ActorClass, EDirectiveUtilInclusivity Inclusivity = Include); + + /** + * Filter the provided actors based on the provided tags. + * @param Actors The list of actors to filter. + * @param FilteredActors The list of actors that have been filtered. + * @param Tag The tag to filter by. + * @param Inclusivity Whether to include or exclude actors with the provided tags. + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Filters|Actor") + static void FilterActorsByTag(const TArray& Actors, TArray& FilteredActors, const FName Tag, EDirectiveUtilInclusivity Inclusivity = Include); + + /** + * Filter the provided actors based on the provided material name. + * @param Actors The list of actors to filter. + * @param FilteredActors The list of actors that have been filtered. + * @param MaterialName The material name to filter by. + * @param MaterialSource The location to check for the material. + * @param Inclusivity Whether to include or exclude actors with the provided material name. + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Filters|Actor") + static void FilterActorsByMaterialName(const TArray& Actors, TArray& FilteredActors, const FString& MaterialName, EDirectiveUtilSearchLocation MaterialSource, EDirectiveUtilInclusivity Inclusivity = Include); + + /** + * Filter the provided actors based on the provided material reference. + * @param Actors The list of actors to filter. + * @param FilteredActors The list of actors that have been filtered. + * @param Material The material reference to filter by. + * @param MaterialSource The location to check for the material. + * @param Inclusivity Whether to include or exclude actors with the provided material reference. + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Filters|Actor") + static void FilterActorsByMaterial(const TArray& Actors, TArray& FilteredActors, const TSoftObjectPtr& Material, EDirectiveUtilSearchLocation MaterialSource, EDirectiveUtilInclusivity Inclusivity = Include); + + /** + * Filter the provided actors based on the provided static mesh name. + * Uses a case-insensitive substring match against the mesh asset name. + * @param Actors The list of actors to filter. + * @param FilteredActors The list of actors that have been filtered. + * @param StaticMeshName The static mesh name to filter by. + * @param Inclusivity Whether to include or exclude actors with the provided static mesh name. + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Filters|Actor") + static void FilterActorsByStaticMeshName(const TArray& Actors, TArray& FilteredActors, const FString& StaticMeshName, EDirectiveUtilInclusivity Inclusivity = Include); + + /** + * Filter the provided actors based on the provided static mesh reference. + * @param Actors The list of actors to filter. + * @param FilteredActors The list of actors that have been filtered. + * @param StaticMesh The static mesh reference to filter by. + * @param Inclusivity Whether to include or exclude actors with the provided static mesh reference. + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Filters|Actor") + static void FilterActorsByStaticMesh(const TArray& Actors, TArray& FilteredActors, const TSoftObjectPtr& StaticMesh, EDirectiveUtilInclusivity Inclusivity = Include); + + /** + * Filter the provided actors based on the provided vert count range. + * @param Actors The list of actors to filter. + * @param FilteredActors The list of actors that have been filtered. + * @param MinVertCount The minimum vert count to filter by. + * @param MaxVertCount The maximum vert count to filter by. + * @param Inclusivity Whether to include or exclude actors with the provided vert count range. + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Filters|Actor") + static void FilterActorsByVertCount(const TArray& Actors, TArray& FilteredActors, int32 MinVertCount, int32 MaxVertCount, EDirectiveUtilInclusivity Inclusivity = Include); + + /** + * Filter the provided actors based on the provided triangle count range. + * @param Actors The list of actors to filter. + * @param FilteredActors The list of actors that have been filtered. + * @param MinTriCount The minimum triangle count to filter by. + * @param MaxTriCount The maximum triangle count to filter by. + * @param Inclusivity Whether to include or exclude actors with the provided triangle count range. + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Filters|Actor") + static void FilterActorsByTriCount(const TArray& Actors, TArray& FilteredActors, int32 MinTriCount, int32 MaxTriCount, EDirectiveUtilInclusivity Inclusivity = Include); + + /** + * Filter the provided actors based on the provided actor bounds. + * @param Actors The list of actors to filter. + * @param FilteredActors The list of actors that have been filtered. + * @param MinBounds The minimum bounds to filter by. + * @param MaxBounds The maximum bounds to filter by. + * @param Inclusivity Whether to include or exclude actors with the provided bounds. + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Filters|Actor") + static void FilterActorsByBounds(const TArray& Actors, TArray& FilteredActors, const FVector& MinBounds, const FVector& MaxBounds, EDirectiveUtilInclusivity Inclusivity = Include); + + /** + * Filter the provided actors based on the provided static mesh bounds. + * @param Actors The list of actors to filter. + * @param FilteredActors The list of actors that have been filtered. + * @param MinBounds The minimum bounds to filter by. + * @param MaxBounds The maximum bounds to filter by. + * @param Inclusivity Whether to include or exclude actors with the provided bounds. + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Filters|Actor") + static void FilterActorsByStaticMeshBounds(const TArray& Actors, TArray& FilteredActors, const FVector& MinBounds, const FVector& MaxBounds, EDirectiveUtilInclusivity Inclusivity = Include); + + /** + * Filter the provided actors based on the provided world location and radius. + * @param Actors The list of actors to filter. + * @param FilteredActors The list of actors that have been filtered. + * @param WorldLocation The world location to filter by. + * @param Radius The radius to filter by. + * @param Inclusivity Whether to include or exclude actors with the provided world location and radius. + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Filters|Actor") + static void FilterActorsByWorldLocation(const TArray& Actors, TArray& FilteredActors, const FVector& WorldLocation, float Radius, EDirectiveUtilInclusivity Inclusivity = Include); + + /** + * Filter the provided actors based on the provided LOD (Level of Detail) count. + * @param Actors The list of actors to filter. + * @param FilteredActors The list of actors that have been filtered. + * @param MinLODs The minimum LOD count to filter by. + * @param MaxLODs The maximum LOD count to filter by. + * @param Inclusivity Whether to include or exclude actors with the provided LOD count. + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Filters|Actor") + static void FilterActorsByLODCount(const TArray& Actors, TArray& FilteredActors, int32 MinLODs, int32 MaxLODs, EDirectiveUtilInclusivity Inclusivity = Include); + + /** + * Filter the provided actors based on the provided Nanite state. + * @param Actors The list of actors to filter. + * @param FilteredActors The list of actors that have been filtered. + * @param bNaniteEnabled Whether to filter by Nanite enabled or disabled. + * @param Inclusivity Whether to include or exclude actors with the provided Nanite state. + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Filters|Actor") + static void FilterActorsByNaniteState(const TArray& Actors, TArray& FilteredActors, bool bNaniteEnabled, EDirectiveUtilInclusivity Inclusivity = Include); + + /** + * Filter the provided actors based on the provided Lightmap Resolution. + * @param Actors The list of actors to filter. + * @param FilteredActors The list of actors that have been filtered. + * @param MinLightmapResolution The minimum lightmap resolution to filter by. + * @param MaxLightmapResolution The maximum lightmap resolution to filter by. + * @param SearchLocation The location to search from (Actor Override and/or Static Mesh). + * @param Inclusivity Whether to include or exclude actors with the provided lightmap resolution. + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Filters|Actor") + static void FilterActorsByLightmapResolution(const TArray& Actors, TArray& FilteredActors, int32 MinLightmapResolution, int32 MaxLightmapResolution, EDirectiveUtilSearchLocation SearchLocation, EDirectiveUtilInclusivity Inclusivity = Include); + + /** + * Filter the provided actors based on the provided mobility. + * Tests the root component's mobility, matching the actor mobility shown in the editor UI. + * @param Actors The list of actors to filter. + * @param FilteredActors The list of actors that have been filtered. + * @param Mobility The mobility to filter by. + * @param Inclusivity Whether to include or exclude actors with the provided mobility. + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Filters|Actor") + static void FilterActorsByMobility(const TArray& Actors, TArray& FilteredActors, EComponentMobility::Type Mobility, EDirectiveUtilInclusivity Inclusivity = Include); + + /** + * Filter the provided actors based on the provided collision channel. + * @param Actors The list of actors to filter. + * @param FilteredActors The list of actors that have been filtered. + * @param CollisionChannel The collision type to filter by. + * @param Inclusivity Whether to include or exclude actors with the provided collision type. + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Filters|Actor") + static void FilterActorsByCollisionChannel(const TArray& Actors, TArray& FilteredActors, ECollisionChannel CollisionChannel, EDirectiveUtilInclusivity Inclusivity = Include); + + /** + * Filter the provided actors based on the provided collision response. + * @param Actors The list of actors to filter. + * @param FilteredActors The list of actors that have been filtered. + * @param CollisionChannel The collision channel to filter by. + * @param CollisionResponse The collision response to filter by. + * @param Inclusivity Whether to include or exclude actors with the provided collision response. + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Filters|Actor") + static void FilterActorsByCollisionResponse(const TArray& Actors, TArray& FilteredActors, ECollisionChannel CollisionChannel, ECollisionResponse CollisionResponse, EDirectiveUtilInclusivity Inclusivity = Include); + + /** + * Filter the provided actors based on the provided collision-enabled state. + * @param Actors The list of actors to filter. + * @param FilteredActors The list of actors that have been filtered. + * @param CollisionEnabled The collision state to filter by. + * @param Inclusivity Whether to include or exclude actors with the provided collision-enabled state. + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Filters|Actor") + static void FilterActorsByCollisionEnabled(const TArray& Actors, TArray& FilteredActors, ECollisionEnabled::Type CollisionEnabled, EDirectiveUtilInclusivity Inclusivity = Include); + + /** + * Filter the provided actors based on the provided collision profile. + * @param Actors The list of actors to filter. + * @param FilteredActors The list of actors that have been filtered. + * @param CollisionProfile The collision profile to filter by. + * @param Inclusivity Whether to include or exclude actors with the provided collision profile. + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Filters|Actor") + static void FilterActorsByCollisionProfile(const TArray& Actors, TArray& FilteredActors, FName CollisionProfile, EDirectiveUtilInclusivity Inclusivity = Include); + + /** + * Filter the provided actors based on the provided Texture Name. + * @param Actors The list of actors to filter. + * @param FilteredActors The list of actors that have been filtered. + * @param TextureName The texture name to filter by. + * @param Source Chose between searching through material overrides or the base material. + * @param Inclusivity Whether to include or exclude actors with the provided texture name. + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Filters|Actor") + static void FilterActorsByTextureName(const TArray& Actors, TArray& FilteredActors, FString TextureName, EDirectiveUtilSearchLocation Source, EDirectiveUtilInclusivity Inclusivity = Include); + + /** + * Filter the provided actors based on the provided Texture Reference. + * @param Actors The list of actors to filter. + * @param FilteredActors The list of actors that have been filtered. + * @param TextureReference The texture reference to filter by. + * @param Source Chose between searching through material overrides or the base material. + * @param Inclusivity Whether to include or exclude actors with the provided texture reference. + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Filters|Actor") + static void FilterActorsByTexture(const TArray& Actors, TArray& FilteredActors, TSoftObjectPtr TextureReference, EDirectiveUtilSearchLocation Source, EDirectiveUtilInclusivity Inclusivity = Include); + + /** + * Filters the provided actors based on if the actor is empty or not. + * An actor is empty when it has no components, or its only component is a + * plain scene component with no child components. + * @param Actors The list of actors to filter. + * @param FilteredActors The list of actors that have been filtered. + * @param Inclusivity Whether to include or exclude empty actors. + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Filters|Actor") + static void FilterEmptyActors(const TArray& Actors, TArray& FilteredActors, EDirectiveUtilInclusivity Inclusivity = Include); + + /** + * Filter the provided actors based on missing materials. + * @param Actors The list of actors to filter. + * @param FilteredActors The list of actors that have been filtered. + * @param Location The location to search for missing materials. + * @param Inclusivity Whether to include or exclude actors with missing materials. + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Filters|Actor") + static void FilterActorsByMissingMaterials(const TArray& Actors, TArray& FilteredActors, EDirectiveUtilSearchLocation Location, EDirectiveUtilInclusivity Inclusivity = Include); + + /** + * Filter the provided actors based on missing Static Meshes. + * @param Actors The list of actors to filter. + * @param FilteredActors The list of actors that have been filtered. + * @param Inclusivity Whether to include or exclude actors with missing Static Meshes. + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Filters|Actor") + static void FilterActorsByMissingStaticMeshes(const TArray& Actors, TArray& FilteredActors, EDirectiveUtilInclusivity Inclusivity = Include); + + /** + * Filter the provided actors based on missing textures. + * @param Actors The list of actors to filter. + * @param FilteredActors The list of actors that have been filtered. + * @param Location The location to search for missing textures. + * @param Inclusivity Whether to include or exclude actors with missing textures. + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Filters|Actor") + static void FilterActorsByMissingTextures(const TArray& Actors, TArray& FilteredActors, EDirectiveUtilSearchLocation Location, EDirectiveUtilInclusivity Inclusivity = Include); + + + //----------------------------- + // Bounds Calculation + //----------------------------- + + /** + * Check if an actor is within the bounds of a box. + * @param Actor The actor to check. + * @param BoxComponent The box component to check. + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities") + static bool IsActorWithinBoxBounds(AActor* Actor, UBoxComponent* BoxComponent); + + /** + * Check if an actor is within the bounds of a Sphere. + * @param Actor The actor to check. + * @param SphereComponent The sphere component to check. + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities") + static bool IsActorWithinSphereBounds(AActor* Actor, USphereComponent* SphereComponent); + + /** + * Check if an actor is within the bounds of a capsule. + * @param Actor The actor to check. + * @param CapsuleComponent The capsule component to check. + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities") + static bool IsActorWithinCapsuleBounds(AActor* Actor, UCapsuleComponent* CapsuleComponent); + + //----------------------------- + // Getters + //----------------------------- + + /** + * Returns a list of actors based on the provided class and options. + * Get actors within the current level by their class. + * @param FoundActors The list of actors that were found. + * @param ActorClass The class of the actors to select. + * @param SelectionMethod The selection method to use. + * @param Inclusivity Should the search be inclusive or exclusive? + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Select", meta=(AdvancedDisplay=2)) + void GetActorsByClass( + TArray& FoundActors, + TSubclassOf ActorClass, + EDirectiveUtilSelectionMethod SelectionMethod = World, + EDirectiveUtilInclusivity Inclusivity = Include); + + /** + * Returns a list of actors based on the provided asset name and options. + * @param FoundActors The list of actors that were found. + * @param ActorName The name of the actors to select. + * @param SelectionMethod The selection method to use. + * @param Inclusivity Should the search be inclusive or exclusive? + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Select", meta=(AdvancedDisplay=2)) + void GetActorsByName( + TArray& FoundActors, + FString ActorName, + EDirectiveUtilSelectionMethod SelectionMethod = World, + EDirectiveUtilInclusivity Inclusivity = Include); + + /** + * Returns a list of actors based on the provided material reference and options. + * Note: This will only return actors that have a static mesh component. + * @param Material The reference of the material to search by. + * @param FoundActors The list of actors that were found. + * @param MaterialSource The source of the material to search by. + * @param SelectionMethod The selection method to use. + * @param Inclusivity Should the search be inclusive or exclusive? + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Select", meta=(AdvancedDisplay=4)) + void GetActorsByMaterial( + TArray& FoundActors, + const UMaterialInterface* Material, + EDirectiveUtilSearchLocation MaterialSource = BaseAndOverride, + EDirectiveUtilSelectionMethod SelectionMethod = World, + EDirectiveUtilInclusivity Inclusivity = Include); + + /** + * Returns a list of actors based on the provided material reference and options. + * Note: This will only return actors that have a static mesh component. + * @param Material The reference of the material to search by. + * @param FoundActors The list of actors that were found. + * @param MaterialSource The source of the material to search by. + * @param SelectionMethod The selection method to use. + * @param Inclusivity Should the search be inclusive or exclusive? + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Select", meta=(AdvancedDisplay=4)) + void GetActorsByMaterialSoftReference( + TArray& FoundActors, + const TSoftObjectPtr Material, + EDirectiveUtilSearchLocation MaterialSource = BaseAndOverride, + EDirectiveUtilSelectionMethod SelectionMethod = World, + EDirectiveUtilInclusivity Inclusivity = Include); + + /** + * Returns a list of actors based on the provided material name and options. + * Note: This will only return actors that have a static mesh component. + * @param MaterialName The name of the material to search by. + * @param FoundActors The list of actors that were found. + * @param MaterialSource The source of the material to search by. + * @param SelectionMethod The selection method to use. + * @param Inclusivity Should the search be inclusive or exclusive? + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Select", meta=(AdvancedDisplay=4)) + void GetActorsByMaterialName( + TArray& FoundActors, + FString MaterialName, + EDirectiveUtilSearchLocation MaterialSource = BaseAndOverride, + EDirectiveUtilSelectionMethod SelectionMethod = World, + EDirectiveUtilInclusivity Inclusivity = Include); + + /** + * Returns a list of actors based on the provided vert count and options. + * Note: This will only return actors that have a Static Mesh Component. + * @param FoundActors The list of actors that were found. + * @param From The minimum number of vertices to search for. + * @param To The maximum number of vertices to search for. + * @param SelectionMethod The selection method to use. + * @param Inclusivity Should the search be inclusive or exclusive? + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Select", meta=(AdvancedDisplay=3)) + void GetActorsByVertexCount( + TArray& FoundActors, + int32 From, + int32 To, + EDirectiveUtilSelectionMethod SelectionMethod = World, + EDirectiveUtilInclusivity Inclusivity = Include); + + /** + * Returns a list of actors based on the provided triangle count and options. + * Note: This will only return actors that have a Static Mesh Component. + * @param FoundActors The list of actors that were found. + * @param From The minimum number of vertices to search for. + * @param To The maximum number of vertices to search for. + * @param SelectionMethod The selection method to use. + * @param Inclusivity Should the search be inclusive or exclusive? + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Select", meta=(AdvancedDisplay=3)) + void GetActorsByTriCount( + TArray& FoundActors, + int32 From, + int32 To, + EDirectiveUtilSelectionMethod SelectionMethod = World, + EDirectiveUtilInclusivity Inclusivity = Include); + + /** + * Returns a list of actors based on the provided bounding box and options. + * Matches actors whose world-space bounding box lies entirely within the box defined by Min/Max (inclusive). + * @param FoundActors The list of actors that were found. + * @param Min The minimum point of the bounding box. + * @param Max The maximum point of the bounding box. + * @param SelectionMethod The selection method to use. + * @param Inclusivity Should the search be inclusive or exclusive? + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Select", meta=(AdvancedDisplay=3)) + void GetActorsByBoundingBox( + TArray& FoundActors, + FVector Min, + FVector Max, + EDirectiveUtilSelectionMethod SelectionMethod = World, + EDirectiveUtilInclusivity Inclusivity = Include); + + /** + * Returns a list of actors based on the provided mesh size and options. + * @param FoundActors The list of actors that were found. + * @param From The minimum size of the mesh to search for. + * @param To The maximum size of the mesh to search for. + * @param SelectionMethod The selection method to use. + * @param Inclusivity Should the search be inclusive or exclusive? + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Select", meta=(AdvancedDisplay=3)) + void GetActorsByMeshSize( + TArray& FoundActors, + float From, + float To, + EDirectiveUtilSelectionMethod SelectionMethod = World, + EDirectiveUtilInclusivity Inclusivity = Include); + + /** + * Returns a list of actors based on the provided world location, radius, and options. + * @param FoundActors The list of actors that were found. + * @param WorldLocation The world location to search by. + * @param Radius The radius around the provided world location to search by. + * @param SelectionMethod The selection method to use. + * @param Inclusivity Should the search be inclusive or exclusive? + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Select", meta=(AdvancedDisplay=3)) + void GetActorsByWorldLocation( + TArray& FoundActors, + FVector WorldLocation, + float Radius = 1000.f, + EDirectiveUtilSelectionMethod SelectionMethod = World, + EDirectiveUtilInclusivity Inclusivity = Include); + + /** + * Returns a list of actors based on the provided LOD count and options. + * @param FoundActors The list of actors that were found. + * @param From The minimum number of LODs to search for. + * @param To The maximum number of LODs to search for. + * @param SelectionMethod The selection method to use. + * @param Inclusivity Should the search be inclusive or exclusive? + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Select", meta=(AdvancedDisplay=3)) + void GetActorsByLODCount( + TArray& FoundActors, + int32 From = 0, + int32 To = 7, + EDirectiveUtilSelectionMethod SelectionMethod = World, + EDirectiveUtilInclusivity Inclusivity = Include); + + /** + * Returns a list of actors based on if they have Nanite enabled or not. + * Note: This will only return actors that have a Static Mesh Component. + * @param FoundActors The list of actors that were found. + * @param bNaniteEnabled Enable to find Actors with Static Mesh Components that have Nanite enabled. Disable to find Actors with Static Mesh Components that do not have Nanite enabled. + * @param SelectionMethod The selection method to use. + * @param Inclusivity Should the search be inclusive or exclusive? + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Select", meta=(AdvancedDisplay=2)) + void GetActorsByNaniteEnabled( + TArray& FoundActors, + bool bNaniteEnabled, + EDirectiveUtilSelectionMethod SelectionMethod = World, + EDirectiveUtilInclusivity Inclusivity = Include); + + /** + * Returns a list of actors based on the provided Lightmap Resolution and options. + * @param FoundActors The list of actors that were found. + * @param From The minimum lightmap resolution to search for. + * @param To The maximum lightmap resolution to search for. + * @param SelectionMethod The selection method to use. + * @param Inclusivity Should the search be inclusive or exclusive? + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Select", meta=(AdvancedDisplay=3)) + void GetActorsByLightmapResolution( + TArray& FoundActors, + int32 From = 4, + int32 To = 4096, + EDirectiveUtilSelectionMethod SelectionMethod = World, + EDirectiveUtilInclusivity Inclusivity = Include); + + /** + * Returns a list of actors based on the provided mobility and options. + * Tests the root component's mobility, matching the actor mobility shown in the editor UI. + * @param FoundActors The list of actors that were found. + * @param SelectionMethod The selection method to use. + * @param Inclusivity Should the search be inclusive or exclusive? + * @param Mobility The mobility to search for. + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Select", meta=(AdvancedDisplay=2)) + void GetActorsByMobility( + TArray& FoundActors, + EComponentMobility::Type Mobility, + EDirectiveUtilSelectionMethod SelectionMethod = World, + EDirectiveUtilInclusivity Inclusivity = Include); + + /** + * Returns a list of actors based on the provided Static Mesh reference and options. + * @param FoundActors The list of actors that were found. + * @param StaticMesh The Static Mesh to search for. + * @param SelectionMethod The selection method to use. + * @param Inclusivity Should the search be inclusive or exclusive? + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Select", meta=(AdvancedDisplay=2)) + void GetActorsByStaticMesh( + TArray& FoundActors, + UStaticMesh* StaticMesh, + EDirectiveUtilSelectionMethod SelectionMethod = World, + EDirectiveUtilInclusivity Inclusivity = Include); + + /** + * Returns a list of actors based on the provided Static Mesh soft reference and options. + * @param FoundActors The list of actors that were found. + * @param StaticMesh The Static Mesh Soft Reference to search for. + * @param SelectionMethod The selection method to use. + * @param Inclusivity Should the search be inclusive or exclusive? + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Select", meta=(AdvancedDisplay=2)) + void GetActorsByStaticMeshSoftReference( + TArray& FoundActors, + TSoftObjectPtr StaticMesh, + EDirectiveUtilSelectionMethod SelectionMethod = World, + EDirectiveUtilInclusivity Inclusivity = Include); + + /** + * Returns a list of actors based on the provided Static Mesh name and options. + * Uses a case-insensitive substring match against the mesh asset name. + * @param FoundActors The list of actors that were found. + * @param StaticMeshName The Static Mesh name to search for. + * @param SelectionMethod The selection method to use. + * @param Inclusivity Should the search be inclusive or exclusive? + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Select", meta=(AdvancedDisplay=2)) + void GetActorsByStaticMeshName( + TArray& FoundActors, + FString StaticMeshName, + EDirectiveUtilSelectionMethod SelectionMethod = World, + EDirectiveUtilInclusivity Inclusivity = Include); + + /** + * Returns a list of actors based on the provided texture reference and options. + * @param FoundActors The list of actors that were found. + * @param Texture The texture to search for. + * @param SelectionMethod The selection method to use. + * @param Inclusivity Should the search be inclusive or exclusive? + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Select", meta=(AdvancedDisplay=2)) + void GetActorsByTexture( + TArray& FoundActors, + UTexture2D* Texture, + EDirectiveUtilSelectionMethod SelectionMethod = World, + EDirectiveUtilInclusivity Inclusivity = Include); + + /** + * Returns a list of actors based on the provided texture soft reference and options. + * @param FoundActors The list of actors that were found. + * @param Texture The texture soft reference to search for. + * @param SelectionMethod The selection method to use. + * @param Inclusivity Should the search be inclusive or exclusive? + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Select", meta=(AdvancedDisplay=2)) + void GetActorsByTextureSoftReference( + TArray& FoundActors, + TSoftObjectPtr Texture, + EDirectiveUtilSelectionMethod SelectionMethod = World, + EDirectiveUtilInclusivity Inclusivity = Include); + + /** + * Returns a list of actors based on the provided texture name and options. + * @param FoundActors The list of actors that were found. + * @param TextureName The texture name to search for. + * @param SelectionMethod The selection method to use. + * @param Inclusivity Should the search be inclusive or exclusive? + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Select", meta=(AdvancedDisplay=2)) + void GetActorsByTextureName( + TArray& FoundActors, + FString TextureName, + EDirectiveUtilSelectionMethod SelectionMethod = World, + EDirectiveUtilInclusivity Inclusivity = Include); + + /** + * Returns a list of invalid actors. + * @param FoundActors The list of actors that were found. + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Select", meta=(AdvancedDisplay=1)) + void GetInvalidActors(TArray& FoundActors); + + //----------------------------- + // Static Mesh + //----------------------------- + + /** + * Pushes the overriden materials on the provided Static Mesh Component to the source Static Mesh. + * @param StaticMeshComponent The Static Mesh Component to push the materials from. + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Static Mesh") + static void PushOverrideMaterialsToSource(UStaticMeshComponent* StaticMeshComponent); +}; diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Public/Types/DirectiveUtilEditorAssetTypes.h b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Public/Types/DirectiveUtilEditorAssetTypes.h new file mode 100644 index 00000000..1c88a404 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Public/Types/DirectiveUtilEditorAssetTypes.h @@ -0,0 +1,55 @@ +// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License. + +#pragma once + +#include "CoreMinimal.h" +#include "DirectiveUtilEditorAssetTypes.generated.h" + +USTRUCT(BlueprintType) +struct FDirectiveUtilAssetKey +{ + GENERATED_BODY() + + UPROPERTY(BlueprintReadOnly, Category = "Directive Utilities|Asset") + FString AssetName; + + UPROPERTY(BlueprintReadOnly, Category = "Directive Utilities|Asset") + FString AssetClass; + + FDirectiveUtilAssetKey(const FString& InAssetName, const FString& InAssetClass) + : AssetName(InAssetName), AssetClass(InAssetClass) {} + + bool operator==(const FDirectiveUtilAssetKey& Other) const + { + return AssetName == Other.AssetName && AssetClass == Other.AssetClass; + } + + friend uint32 GetTypeHash(const FDirectiveUtilAssetKey& Key) + { + return HashCombine(GetTypeHash(Key.AssetName), GetTypeHash(Key.AssetClass)); + } + + FDirectiveUtilAssetKey(): AssetName(FString()), AssetClass(FString()) {} +}; + +USTRUCT(BlueprintType) +struct FDirectiveUtilDuplicateAssetData +{ + GENERATED_BODY() + + UPROPERTY(BlueprintReadOnly, Category = "Directive Utilities|Asset") + FString AssetName; + + UPROPERTY(BlueprintReadOnly, Category = "Directive Utilities|Asset") + FString AssetClass; + + UPROPERTY(BlueprintReadOnly, Category = "Directive Utilities|Asset") + TArray DuplicateAssetPaths; + + FDirectiveUtilDuplicateAssetData() + { + AssetName = FString(); + AssetClass = FString(); + DuplicateAssetPaths = TArray(); + } +}; \ No newline at end of file diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Public/Types/DirectiveUtilEditorTypes.h b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Public/Types/DirectiveUtilEditorTypes.h new file mode 100644 index 00000000..ef97d0c1 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Public/Types/DirectiveUtilEditorTypes.h @@ -0,0 +1,43 @@ +// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License. + +#pragma once + +#include "CoreMinimal.h" + +/** + * EDirectiveUtilSelectionMethod + * + * The method type used to select actors within the world. + */ +UENUM(BlueprintType, Category = "Directive Utilities") +enum EDirectiveUtilSelectionMethod : uint8 +{ + World UMETA(DisplayName = "World", Tooltip="Select based on the actors within the world."), + Selection UMETA(DisplayName = "Selection", Tooltip="Select based on the actors within the current selection."), +}; + +/** + * EDirectiveUtilInclusivity + * + * The inclusivity type used to select actors within the world. + */ +UENUM(BlueprintType, Category = "Directive Utilities") +enum EDirectiveUtilInclusivity : uint8 +{ + Include UMETA(DisplayName = "Include", Tooltip="Include items based on the provided criteria."), + Exclude UMETA(DisplayName = "Exclude", Tooltip="Exclude items based on the provided criteria."), +}; + +/** + * EDirectiveUtilSearchLocation + * + * The object source to use. + */ +UENUM(BlueprintType, Category = "Directive Utilities") +enum EDirectiveUtilSearchLocation : uint8 +{ + BaseAndOverride UMETA(DisplayName = "Base & Override", + Tooltip="With search the base object along with actor overrides."), + BaseOnly UMETA(DisplayName = "Base Only", Tooltip="Will only search the base object."), + OverrideOnly UMETA(DisplayName = "Override Only", Tooltip="Will only search actor overrides."), +}; diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/DirectiveUtilitiesRuntime.Build.cs b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/DirectiveUtilitiesRuntime.Build.cs new file mode 100644 index 00000000..d912401f --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/DirectiveUtilitiesRuntime.Build.cs @@ -0,0 +1,42 @@ +// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License. + +using UnrealBuildTool; + +public class DirectiveUtilitiesRuntime : ModuleRules +{ + public DirectiveUtilitiesRuntime(ReadOnlyTargetRules Target) : base(Target) + { + PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs; + + + PublicDependencyModuleNames.AddRange( + new string[] + { + "Core", + "GameplayTags", + "NetCore", + } + ); + + + PrivateDependencyModuleNames.AddRange( + new string[] + { + "CoreUObject", + "Engine", + "Slate", + "SlateCore", + "AIModule", + "EnhancedInput", + "ApplicationCore", + } + ); + + + DynamicallyLoadedModuleNames.AddRange( + new string[] + { + } + ); + } +} \ No newline at end of file diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/DirectiveUtilLogChannels.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/DirectiveUtilLogChannels.cpp new file mode 100644 index 00000000..f95f5cdd --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/DirectiveUtilLogChannels.cpp @@ -0,0 +1,4 @@ +#include "DirectiveUtilLogChannels.h" + +DEFINE_LOG_CATEGORY(LogDirectiveUtil); +DEFINE_LOG_CATEGORY(LogDirectiveUtilEditor); \ No newline at end of file diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/DirectiveUtilitiesRuntime.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/DirectiveUtilitiesRuntime.cpp new file mode 100644 index 00000000..60d1591b --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/DirectiveUtilitiesRuntime.cpp @@ -0,0 +1,13 @@ +// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License. + +#include "DirectiveUtilitiesRuntime.h" + +void FDirectiveUtilitiesRuntimeModule::StartupModule() +{ +} + +void FDirectiveUtilitiesRuntimeModule::ShutdownModule() +{ +} + +IMPLEMENT_MODULE(FDirectiveUtilitiesRuntimeModule, DirectiveUtilitiesRuntime) diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilArrayFunctionLibrary.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilArrayFunctionLibrary.cpp new file mode 100644 index 00000000..bec527eb --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilArrayFunctionLibrary.cpp @@ -0,0 +1,565 @@ +// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License. + + +#include "Libraries/DirectiveUtilArrayFunctionLibrary.h" + +int32 UDirectiveUtilArrayFunctionLibrary::Array_NextIndex(const TArray& TargetArray, const int32 Index, const bool bLoop) +{ + checkNoEntry(); + return 0; +} + +int32 UDirectiveUtilArrayFunctionLibrary::Array_PreviousIndex( + const TArray& TargetArray, + const int32 Index, + const bool bLoop) +{ + checkNoEntry(); + return 0; +} + +int32 UDirectiveUtilArrayFunctionLibrary::GenericArray_NextIndex( + const void* TargetArray, + const FArrayProperty* ArrayProperty, + const int32 Index, + const bool bLoop) +{ + if (!TargetArray || !ArrayProperty) + { + return INDEX_NONE; + } + + const FScriptArrayHelper ArrayHelper(ArrayProperty, TargetArray); + const int32 NextIndex = Index + 1; + + if(ArrayHelper.Num() == 0) + { + return INDEX_NONE; + } + + if(NextIndex < 0) + { + return 0; + } + + if (NextIndex <= ArrayHelper.Num() - 1) + { + return NextIndex; + } + + if (bLoop) + { + return 0; + } + + return ArrayHelper.Num() - 1; +} + +void UDirectiveUtilArrayFunctionLibrary::Array_RemoveDuplicates(const TArray& TargetArray) +{ + checkNoEntry(); +} + +void UDirectiveUtilArrayFunctionLibrary::GenericArray_RemoveDuplicates( + void* TargetArray, + const FArrayProperty* ArrayProperty) +{ + if (!TargetArray || !ArrayProperty) + { + return; + } + + FScriptArrayHelper ArrayHelper(ArrayProperty, TargetArray); + const FProperty* InnerProp = ArrayProperty->Inner; + + for (int32 OuterIndex = ArrayHelper.Num() - 1; OuterIndex > 0; --OuterIndex) + { + for (int32 InnerIndex = 0; InnerIndex < OuterIndex; ++InnerIndex) + { + if (InnerProp->Identical(ArrayHelper.GetElementPtr(OuterIndex), ArrayHelper.GetElementPtr(InnerIndex))) + { + ArrayHelper.RemoveValues(OuterIndex); + break; + } + } + } +} + +int32 UDirectiveUtilArrayFunctionLibrary::GenericArray_PreviousIndex( + const void* TargetArray, + const FArrayProperty* ArrayProperty, + const int32 Index, + const bool bLoop) +{ + if (!TargetArray || !ArrayProperty) + { + return INDEX_NONE; + } + + const FScriptArrayHelper ArrayHelper(ArrayProperty, TargetArray); + const int32 PreviousIndex = Index - 1; + + if(ArrayHelper.Num() == 0) + { + return INDEX_NONE; + } + + if(PreviousIndex > ArrayHelper.Num() - 1) + { + return ArrayHelper.Num() - 1; + } + + if (PreviousIndex >= 0) + { + return PreviousIndex; + } + + if (bLoop) + { + return ArrayHelper.Num() - 1; + } + + return 0; +} + +bool UDirectiveUtilArrayFunctionLibrary::Array_GetValidFirstItemCopy(const TArray& TargetArray, int32& OutItem) +{ + checkNoEntry(); + return false; +} + +bool UDirectiveUtilArrayFunctionLibrary::Array_GetValidLastItemCopy(const TArray& TargetArray, int32& OutItem) +{ + checkNoEntry(); + return false; +} + +bool UDirectiveUtilArrayFunctionLibrary::Array_GetValidItemFromIndexCopy(const TArray& TargetArray, const int32 Index, int32& OutItem) +{ + checkNoEntry(); + return false; +} + +bool UDirectiveUtilArrayFunctionLibrary::Array_GetRandomItem(const TArray& TargetArray, int32& OutItem, int32& OutIndex) +{ + checkNoEntry(); + return false; +} + +void UDirectiveUtilArrayFunctionLibrary::Array_LastValue(const TArray& TargetArray, int32& OutItem) +{ + checkNoEntry(); +} + +bool UDirectiveUtilArrayFunctionLibrary::Array_Pop(const TArray& TargetArray, int32& OutItem) +{ + checkNoEntry(); + return false; +} + +bool UDirectiveUtilArrayFunctionLibrary::Array_PopFirst(const TArray& TargetArray, int32& OutItem) +{ + checkNoEntry(); + return false; +} + +bool UDirectiveUtilArrayFunctionLibrary::Array_RemoveAtSwap(const TArray& TargetArray, const int32 Index) +{ + checkNoEntry(); + return false; +} + +bool UDirectiveUtilArrayFunctionLibrary::GenericArray_GetItemAtIndex( + const void* TargetArray, + const FArrayProperty* ArrayProperty, + const int32 Index, + void* OutItemPtr) +{ + if (!TargetArray || !ArrayProperty) + { + return false; + } + + FScriptArrayHelper ArrayHelper(ArrayProperty, TargetArray); + const FProperty* InnerProp = ArrayProperty->Inner; + + if (!ArrayHelper.IsValidIndex(Index)) + { + if (OutItemPtr) + { + InnerProp->ClearValue(OutItemPtr); + } + return false; + } + + if (OutItemPtr) + { + InnerProp->CopyCompleteValueFromScriptVM(OutItemPtr, ArrayHelper.GetRawPtr(Index)); + } + return true; +} + +bool UDirectiveUtilArrayFunctionLibrary::GenericArray_GetFirstItem( + const void* TargetArray, + const FArrayProperty* ArrayProperty, + void* OutItemPtr) +{ + return GenericArray_GetItemAtIndex(TargetArray, ArrayProperty, 0, OutItemPtr); +} + +bool UDirectiveUtilArrayFunctionLibrary::GenericArray_GetLastItem( + const void* TargetArray, + const FArrayProperty* ArrayProperty, + void* OutItemPtr) +{ + if (!TargetArray || !ArrayProperty) + { + return false; + } + + const FScriptArrayHelper ArrayHelper(ArrayProperty, TargetArray); + return GenericArray_GetItemAtIndex(TargetArray, ArrayProperty, ArrayHelper.Num() - 1, OutItemPtr); +} + +bool UDirectiveUtilArrayFunctionLibrary::GenericArray_GetRandomItem( + const void* TargetArray, + const FArrayProperty* ArrayProperty, + void* OutItemPtr, + int32* OutIndex) +{ + if (OutIndex) + { + *OutIndex = INDEX_NONE; + } + + if (!TargetArray || !ArrayProperty) + { + return false; + } + + const FScriptArrayHelper ArrayHelper(ArrayProperty, TargetArray); + const int32 Num = ArrayHelper.Num(); + if (Num <= 0) + { + if (OutItemPtr) + { + ArrayProperty->Inner->ClearValue(OutItemPtr); + } + return false; + } + + const int32 Index = FMath::RandRange(0, Num - 1); + if (OutIndex) + { + *OutIndex = Index; + } + return GenericArray_GetItemAtIndex(TargetArray, ArrayProperty, Index, OutItemPtr); +} + +bool UDirectiveUtilArrayFunctionLibrary::GenericArray_Pop( + void* TargetArray, + const FArrayProperty* ArrayProperty, + void* OutItemPtr) +{ + if (!TargetArray || !ArrayProperty) + { + return false; + } + + FScriptArrayHelper ArrayHelper(ArrayProperty, TargetArray); + const FProperty* InnerProp = ArrayProperty->Inner; + const int32 LastIndex = ArrayHelper.Num() - 1; + + if (LastIndex < 0) + { + if (OutItemPtr) + { + InnerProp->ClearValue(OutItemPtr); + } + return false; + } + + if (OutItemPtr) + { + InnerProp->CopyCompleteValueFromScriptVM(OutItemPtr, ArrayHelper.GetRawPtr(LastIndex)); + } + ArrayHelper.RemoveValues(LastIndex, 1); + return true; +} + +bool UDirectiveUtilArrayFunctionLibrary::GenericArray_PopFirst( + void* TargetArray, + const FArrayProperty* ArrayProperty, + void* OutItemPtr) +{ + if (!TargetArray || !ArrayProperty) + { + return false; + } + + FScriptArrayHelper ArrayHelper(ArrayProperty, TargetArray); + const FProperty* InnerProp = ArrayProperty->Inner; + + if (ArrayHelper.Num() <= 0) + { + if (OutItemPtr) + { + InnerProp->ClearValue(OutItemPtr); + } + return false; + } + + if (OutItemPtr) + { + InnerProp->CopyCompleteValueFromScriptVM(OutItemPtr, ArrayHelper.GetRawPtr(0)); + } + ArrayHelper.RemoveValues(0, 1); + return true; +} + +bool UDirectiveUtilArrayFunctionLibrary::GenericArray_RemoveAtSwap( + void* TargetArray, + const FArrayProperty* ArrayProperty, + const int32 Index) +{ + if (!TargetArray || !ArrayProperty) + { + return false; + } + + FScriptArrayHelper ArrayHelper(ArrayProperty, TargetArray); + if (!ArrayHelper.IsValidIndex(Index)) + { + return false; + } + + const int32 LastIndex = ArrayHelper.Num() - 1; + if (Index != LastIndex) + { + ArrayHelper.SwapValues(Index, LastIndex); + } + ArrayHelper.RemoveValues(LastIndex, 1); + return true; +} + +void UDirectiveUtilArrayFunctionLibrary::Array_Slice(const TArray& TargetArray, const int32 StartIndex, const int32 Count, TArray& OutArray) +{ + checkNoEntry(); +} + +void UDirectiveUtilArrayFunctionLibrary::Array_Rotate(const TArray& TargetArray, const int32 Shift) +{ + checkNoEntry(); +} + +void UDirectiveUtilArrayFunctionLibrary::Array_GetDistinct(const TArray& TargetArray, TArray& OutArray) +{ + checkNoEntry(); +} + +int32 UDirectiveUtilArrayFunctionLibrary::Array_CountOccurrences(const TArray& TargetArray, const int32& ItemToCount) +{ + checkNoEntry(); + return 0; +} + +bool UDirectiveUtilArrayFunctionLibrary::Array_GetMostCommon(const TArray& TargetArray, int32& OutItem, int32& OutCount) +{ + checkNoEntry(); + return false; +} + +void UDirectiveUtilArrayFunctionLibrary::GenericArray_Slice( + const void* TargetArray, + const FArrayProperty* TargetArrayProperty, + const int32 StartIndex, + const int32 Count, + void* OutArray, + const FArrayProperty* OutArrayProperty) +{ + if (!TargetArray || !OutArray || !TargetArrayProperty || !OutArrayProperty) + { + return; + } + + FScriptArrayHelper SourceHelper(TargetArrayProperty, TargetArray); + FScriptArrayHelper OutHelper(OutArrayProperty, OutArray); + OutHelper.EmptyValues(); + + const int32 Num = SourceHelper.Num(); + if (Num == 0 || Count <= 0) + { + return; + } + + const FProperty* InnerProp = TargetArrayProperty->Inner; + const int32 Start = FMath::Clamp(StartIndex, 0, Num); + const int32 NumToCopy = FMath::Min(Count, Num - Start); + for (int32 Offset = 0; Offset < NumToCopy; ++Offset) + { + const int32 OutIndex = OutHelper.AddValue(); + InnerProp->CopySingleValueToScriptVM(OutHelper.GetRawPtr(OutIndex), SourceHelper.GetRawPtr(Start + Offset)); + } +} + +void UDirectiveUtilArrayFunctionLibrary::GenericArray_Rotate( + void* TargetArray, + const FArrayProperty* ArrayProperty, + const int32 Shift) +{ + if (!TargetArray || !ArrayProperty) + { + return; + } + + FScriptArrayHelper ArrayHelper(ArrayProperty, TargetArray); + const int32 Num = ArrayHelper.Num(); + if (Num <= 1) + { + return; + } + + int32 Normalized = Shift % Num; + if (Normalized < 0) + { + Normalized += Num; + } + if (Normalized == 0) + { + return; + } + + auto ReverseRange = [&ArrayHelper](int32 Low, int32 High) + { + while (Low < High) + { + ArrayHelper.SwapValues(Low, High); + ++Low; + --High; + } + }; + ReverseRange(0, Num - 1); + ReverseRange(0, Normalized - 1); + ReverseRange(Normalized, Num - 1); +} + +void UDirectiveUtilArrayFunctionLibrary::GenericArray_GetDistinct( + const void* TargetArray, + const FArrayProperty* TargetArrayProperty, + void* OutArray, + const FArrayProperty* OutArrayProperty) +{ + if (!TargetArray || !OutArray || !TargetArrayProperty || !OutArrayProperty) + { + return; + } + + FScriptArrayHelper SourceHelper(TargetArrayProperty, TargetArray); + FScriptArrayHelper OutHelper(OutArrayProperty, OutArray); + OutHelper.EmptyValues(); + + const FProperty* InnerProp = TargetArrayProperty->Inner; + const int32 Num = SourceHelper.Num(); + for (int32 SourceIndex = 0; SourceIndex < Num; ++SourceIndex) + { + const uint8* SourceElement = SourceHelper.GetRawPtr(SourceIndex); + + bool bIsDuplicate = false; + for (int32 ExistingIndex = 0; ExistingIndex < OutHelper.Num(); ++ExistingIndex) + { + if (InnerProp->Identical(SourceElement, OutHelper.GetRawPtr(ExistingIndex))) + { + bIsDuplicate = true; + break; + } + } + + if (!bIsDuplicate) + { + const int32 OutIndex = OutHelper.AddValue(); + InnerProp->CopySingleValueToScriptVM(OutHelper.GetRawPtr(OutIndex), SourceElement); + } + } +} + +int32 UDirectiveUtilArrayFunctionLibrary::GenericArray_CountOccurrences( + const void* TargetArray, + const FArrayProperty* ArrayProperty, + const void* ItemToCount) +{ + if (!TargetArray || !ArrayProperty || !ItemToCount) + { + return 0; + } + + FScriptArrayHelper ArrayHelper(ArrayProperty, TargetArray); + const FProperty* InnerProp = ArrayProperty->Inner; + + int32 Count = 0; + for (int32 Index = 0; Index < ArrayHelper.Num(); ++Index) + { + if (InnerProp->Identical(ArrayHelper.GetRawPtr(Index), ItemToCount)) + { + ++Count; + } + } + return Count; +} + +bool UDirectiveUtilArrayFunctionLibrary::GenericArray_GetMostCommon( + const void* TargetArray, + const FArrayProperty* ArrayProperty, + void* OutItemPtr, + int32* OutCount) +{ + if (OutCount) + { + *OutCount = 0; + } + + if (!TargetArray || !ArrayProperty) + { + return false; + } + + FScriptArrayHelper ArrayHelper(ArrayProperty, TargetArray); + const FProperty* InnerProp = ArrayProperty->Inner; + const int32 Num = ArrayHelper.Num(); + if (Num == 0) + { + if (OutItemPtr) + { + InnerProp->ClearValue(OutItemPtr); + } + return false; + } + + int32 BestIndex = 0; + int32 BestCount = 0; + for (int32 Index = 0; Index < Num; ++Index) + { + int32 CurrentCount = 0; + for (int32 CompareIndex = 0; CompareIndex < Num; ++CompareIndex) + { + if (InnerProp->Identical(ArrayHelper.GetRawPtr(Index), ArrayHelper.GetRawPtr(CompareIndex))) + { + ++CurrentCount; + } + } + if (CurrentCount > BestCount) + { + BestCount = CurrentCount; + BestIndex = Index; + } + } + + if (OutItemPtr) + { + InnerProp->CopyCompleteValueFromScriptVM(OutItemPtr, ArrayHelper.GetRawPtr(BestIndex)); + } + if (OutCount) + { + *OutCount = BestCount; + } + return true; +} diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilFunctionLibrary.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilFunctionLibrary.cpp new file mode 100644 index 00000000..19427041 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilFunctionLibrary.cpp @@ -0,0 +1,87 @@ +// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License. + + +#include "Libraries/DirectiveUtilFunctionLibrary.h" +#include "HAL/PlatformApplicationMisc.h" +#include "Misc/CommandLine.h" +#include "Misc/ConfigCacheIni.h" + +void UDirectiveUtilFunctionLibrary::GetChildClasses(const UClass* BaseClass, const bool bRecursive, TArray& DerivedClasses) +{ + GetDerivedClasses(BaseClass, DerivedClasses, bRecursive); +} + +void UDirectiveUtilFunctionLibrary::CopyTextToClipboard(const FText& Text) +{ + const FString ClipboardText = Text.ToString(); + FPlatformApplicationMisc::ClipboardCopy(*ClipboardText); +} + +void UDirectiveUtilFunctionLibrary::CopyStringToClipboard(const FString& String) +{ + FPlatformApplicationMisc::ClipboardCopy(*String); +} + +FText UDirectiveUtilFunctionLibrary::GetTextFromClipboard() +{ + FString ClipboardText; + FPlatformApplicationMisc::ClipboardPaste(ClipboardText); + return FText::FromString(ClipboardText); +} + +FString UDirectiveUtilFunctionLibrary::GetStringFromClipboard() +{ + FString ClipboardText; + FPlatformApplicationMisc::ClipboardPaste(ClipboardText); + return ClipboardText; +} + +void UDirectiveUtilFunctionLibrary::ClearClipboard() +{ + FPlatformApplicationMisc::ClipboardCopy(TEXT("")); +} + +FString UDirectiveUtilFunctionLibrary::GetProjectVersion() +{ + FString ProjectVersion; + GConfig->GetString( + TEXT("/Script/EngineSettings.GeneralProjectSettings"), + TEXT("ProjectVersion"), + ProjectVersion, + GGameIni); + return ProjectVersion; +} + +bool UDirectiveUtilFunctionLibrary::IsRunningInEditor() +{ + return GIsEditor; +} + +bool UDirectiveUtilFunctionLibrary::HasCommandLineSwitch(const FString& Switch) +{ + return HasCommandLineSwitch(FCommandLine::Get(), Switch); +} + +bool UDirectiveUtilFunctionLibrary::GetCommandLineOption(const FString& Key, FString& OutValue) +{ + return GetCommandLineOption(FCommandLine::Get(), Key, OutValue); +} + +bool UDirectiveUtilFunctionLibrary::HasCommandLineSwitch(const TCHAR* CommandLine, const FString& Switch) +{ + if (Switch.IsEmpty()) + { + return false; + } + return FParse::Param(CommandLine, *Switch); +} + +bool UDirectiveUtilFunctionLibrary::GetCommandLineOption(const TCHAR* CommandLine, const FString& Key, FString& OutValue) +{ + OutValue.Reset(); + if (Key.IsEmpty()) + { + return false; + } + return FParse::Value(CommandLine, *(Key + TEXT("=")), OutValue); +} diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilGameplayTagFunctionLibrary.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilGameplayTagFunctionLibrary.cpp new file mode 100644 index 00000000..62a47aa7 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilGameplayTagFunctionLibrary.cpp @@ -0,0 +1,164 @@ +// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License. + + +#include "Libraries/DirectiveUtilGameplayTagFunctionLibrary.h" +#include "GameplayTagsManager.h" + +FGameplayTag UDirectiveUtilGameplayTagFunctionLibrary::GetTagDirectParent(const FGameplayTag& Tag) +{ + if (!Tag.IsValid()) + { + return FGameplayTag(); + } + return Tag.RequestDirectParent(); +} + +FGameplayTagContainer UDirectiveUtilGameplayTagFunctionLibrary::GetTagParents(const FGameplayTag& Tag) +{ + if (!Tag.IsValid()) + { + return FGameplayTagContainer(); + } + + FGameplayTagContainer Parents = Tag.GetGameplayTagParents(); + Parents.RemoveTag(Tag); + return Parents; +} + +int32 UDirectiveUtilGameplayTagFunctionLibrary::GetTagDepth(const FGameplayTag& Tag) +{ + return GetTagSegments(Tag).Num(); +} + +FString UDirectiveUtilGameplayTagFunctionLibrary::GetTagLeafName(const FGameplayTag& Tag) +{ + const TArray Segments = GetTagSegments(Tag); + return Segments.Num() > 0 ? Segments.Last() : FString(); +} + +TArray UDirectiveUtilGameplayTagFunctionLibrary::GetTagSegments(const FGameplayTag& Tag) +{ + TArray Segments; + if (!Tag.IsValid()) + { + return Segments; + } + Tag.GetTagName().ToString().ParseIntoArray(Segments, TEXT("."), true); + return Segments; +} + +FGameplayTagContainer UDirectiveUtilGameplayTagFunctionLibrary::GetTagChildren(const FGameplayTag& Tag) +{ + if (!Tag.IsValid()) + { + return FGameplayTagContainer(); + } + return UGameplayTagsManager::Get().RequestGameplayTagChildren(Tag); +} + +FGameplayTagContainer UDirectiveUtilGameplayTagFunctionLibrary::GetTagDirectChildren(const FGameplayTag& Tag) +{ + FGameplayTagContainer DirectChildren; + if (!Tag.IsValid()) + { + return DirectChildren; + } + + const int32 DirectChildDepth = GetTagDepth(Tag) + 1; + for (const FGameplayTag& Child : GetTagChildren(Tag)) + { + if (GetTagDepth(Child) == DirectChildDepth) + { + DirectChildren.AddTag(Child); + } + } + return DirectChildren; +} + +FGameplayTag UDirectiveUtilGameplayTagFunctionLibrary::GetTagCommonAncestor(const FGameplayTag& TagA, const FGameplayTag& TagB) +{ + const TArray SegmentsA = GetTagSegments(TagA); + const TArray SegmentsB = GetTagSegments(TagB); + + FString Prefix; + for (int32 Index = 0; Index < SegmentsA.Num() && Index < SegmentsB.Num(); ++Index) + { + if (!SegmentsA[Index].Equals(SegmentsB[Index])) + { + break; + } + if (!Prefix.IsEmpty()) + { + Prefix += TEXT("."); + } + Prefix += SegmentsA[Index]; + } + + if (Prefix.IsEmpty()) + { + return FGameplayTag(); + } + // A common prefix of two registered tags is itself registered (parents auto-register). + return FGameplayTag::RequestGameplayTag(FName(*Prefix), false); +} + +FGameplayTag UDirectiveUtilGameplayTagFunctionLibrary::GetTagAtDepth(const FGameplayTag& Tag, const int32 Depth) +{ + if (!Tag.IsValid() || Depth < 1) + { + return FGameplayTag(); + } + + const TArray Segments = GetTagSegments(Tag); + if (Depth >= Segments.Num()) + { + return Tag; + } + + FString Prefix = Segments[0]; + for (int32 Index = 1; Index < Depth; ++Index) + { + Prefix += TEXT("."); + Prefix += Segments[Index]; + } + // An ancestor of a registered tag is always registered itself (parents auto-register). + return FGameplayTag::RequestGameplayTag(FName(*Prefix), false); +} + +FGameplayTagContainer UDirectiveUtilGameplayTagFunctionLibrary::GetTagSiblings(const FGameplayTag& Tag) +{ + const FGameplayTag DirectParent = GetTagDirectParent(Tag); + if (!DirectParent.IsValid()) + { + return FGameplayTagContainer(); + } + + FGameplayTagContainer Siblings = GetTagDirectChildren(DirectParent); + Siblings.RemoveTag(Tag); + return Siblings; +} + +bool UDirectiveUtilGameplayTagFunctionLibrary::IsLeafTag(const FGameplayTag& Tag) +{ + return Tag.IsValid() && GetTagChildren(Tag).IsEmpty(); +} + +TArray UDirectiveUtilGameplayTagFunctionLibrary::FindRegisteredTags(const FString& Substring) +{ + TArray MatchingTags; + if (Substring.IsEmpty()) + { + return MatchingTags; + } + + FGameplayTagContainer AllTags; + UGameplayTagsManager::Get().RequestAllGameplayTags(AllTags, /*OnlyIncludeDictionaryTags*/ false); + for (const FGameplayTag& RegisteredTag : AllTags) + { + if (RegisteredTag.GetTagName().ToString().Contains(Substring)) + { + MatchingTags.Add(RegisteredTag); + } + } + return MatchingTags; +} diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilInputFunctionLibrary.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilInputFunctionLibrary.cpp new file mode 100644 index 00000000..f8386f80 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilInputFunctionLibrary.cpp @@ -0,0 +1,218 @@ +// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License. + + +#include "Libraries/DirectiveUtilInputFunctionLibrary.h" +#include "EnhancedInputSubsystems.h" +#include "InputMappingContext.h" +#include "DirectiveUtilLogChannels.h" +#include "Logging/StructuredLog.h" +#include "GameFramework/Controller.h" +#include "GameFramework/PlayerController.h" +#include "Engine/LocalPlayer.h" + +bool UDirectiveUtilInputFunctionLibrary::TryGetEnhancedInputSubsystemFromController( + AController* PlayerController, + UEnhancedInputLocalPlayerSubsystem*& EnhancedInput) +{ + if (!PlayerController) + { + UE_LOG(LogDirectiveUtil, Warning, TEXT("PlayerController is null. Cannot set input mapping contexts.")); + return false; + } + + const APlayerController* PC = Cast(PlayerController); + if (!PC) + { + UE_LOG(LogDirectiveUtil, Warning, TEXT("Controller is not a PlayerController. Cannot get enhanced input subsystem.")); + return false; + } + + const ULocalPlayer* LocalPlayer = PC->GetLocalPlayer(); + if (!LocalPlayer) + { + UE_LOG(LogDirectiveUtil, Warning, TEXT("LocalPlayer not found. Cannot set input mapping contexts.")); + return false; + } + + EnhancedInput = LocalPlayer->GetSubsystem(); + + if(!EnhancedInput) + { + UE_LOG(LogDirectiveUtil, Warning, TEXT("EnhancedInput subsystem not found. Cannot remove input mapping contexts.")); + return false; + } + + return true; +} + +EDirectiveUtilSuccessStatus UDirectiveUtilInputFunctionLibrary::AddInputMappingContexts( + AController* PlayerController, + const TArray& Contexts, + const bool bClearPrevious) +{ + if (Contexts.IsEmpty()) { return EDirectiveUtilSuccessStatus::Failure; } + + UEnhancedInputLocalPlayerSubsystem* EnhancedInput; + const bool bEnhancedInputRetrievedFromController = TryGetEnhancedInputSubsystemFromController(PlayerController, EnhancedInput); + + if(!bEnhancedInputRetrievedFromController) + { + return EDirectiveUtilSuccessStatus::Failure; + } + + TArray> LoadedContexts; + TArray FailedIndices; + for (int32 Index = 0; Index < Contexts.Num(); ++Index) + { + const auto& [InputContext, Priority] = Contexts[Index]; + if (const UInputMappingContext* MappingContext = InputContext.LoadSynchronous()) + { + LoadedContexts.Emplace(MappingContext, Priority); + } + else + { + FailedIndices.Add(Index); + } + } + + if (FailedIndices.Num() > 0) + { + FString FailedIndicesStr = FString::JoinBy(FailedIndices, TEXT(", "), [](const int32 Index) { return FString::Printf(TEXT("%d"), Index); }); + UE_LOGFMT(LogDirectiveUtil, Warning, "{FailedIndicies} Input Mapping Contexts failed to load and were not added! The failed indexes are [{FailedIndicieIndexes}]", FailedIndices.Num(), FailedIndicesStr); + } + + if (LoadedContexts.IsEmpty()) + { + return EDirectiveUtilSuccessStatus::Failure; + } + + if (bClearPrevious) + { + EnhancedInput->ClearAllMappings(); + } + + for (const auto& [MappingContext, Priority] : LoadedContexts) + { + EnhancedInput->AddMappingContext(MappingContext, Priority); + } + + UE_LOGFMT(LogDirectiveUtil, Verbose, "{MappingCount} Input mapping contexts set successfully.", Contexts.Num() - FailedIndices.Num()); + return EDirectiveUtilSuccessStatus::Success; +} + +EDirectiveUtilSuccessStatus UDirectiveUtilInputFunctionLibrary::RemoveInputMappingContexts( + AController* PlayerController, + const TArray>& Contexts) +{ + if (Contexts.IsEmpty()) { return EDirectiveUtilSuccessStatus::Failure;; } + + UEnhancedInputLocalPlayerSubsystem* EnhancedInput; + const bool bEnhancedInputRetrievedFromController = TryGetEnhancedInputSubsystemFromController(PlayerController, EnhancedInput); + + if(!bEnhancedInputRetrievedFromController) + { + return EDirectiveUtilSuccessStatus::Failure; + } + + TArray FailedIndices; + for (int32 Index = 0; Index < Contexts.Num(); ++Index) + { + const TSoftObjectPtr& Context = Contexts[Index]; + if (const UInputMappingContext* MappingContext = Context.LoadSynchronous()) + { + EnhancedInput->RemoveMappingContext(MappingContext); + } else + { + FailedIndices.Add(Index); + } + } + + if (FailedIndices.Num() > 0) + { + FString FailedIndicesStr = FString::JoinBy(FailedIndices, TEXT(", "), [](const int32 Index) { return FString::Printf(TEXT("%d"), Index); }); + UE_LOGFMT(LogDirectiveUtil, Warning, "{FailedIndicies} Input Mapping Contexts failed to load and were not removed! The failed indexes are [{FailedIndicieIndexes}]", FailedIndices.Num(), FailedIndicesStr); + } + + if (FailedIndices.Num() == Contexts.Num()) + { + return EDirectiveUtilSuccessStatus::Failure; + } + + UE_LOGFMT(LogDirectiveUtil, Verbose, "{MappingCount} Input mapping contexts removed successfully.", Contexts.Num() - FailedIndices.Num()); + return EDirectiveUtilSuccessStatus::Success; +} + +EDirectiveUtilSuccessStatus UDirectiveUtilInputFunctionLibrary::SwapInputMappingContexts( + AController* PlayerController, + const TSoftObjectPtr PreviousContext, + const TSoftObjectPtr NewContext, + const int32 Priority, + const bool bUsePreviousPriority) +{ + const UInputMappingContext* LoadedPreviousMappingContext = PreviousContext.LoadSynchronous(); + const UInputMappingContext* LoadedNewMappingContext = NewContext.LoadSynchronous(); + + if (!LoadedPreviousMappingContext || !LoadedNewMappingContext) + { + UE_LOGFMT(LogDirectiveUtil, Warning, "Both the previous and new input mapping contexts must be valid."); + return EDirectiveUtilSuccessStatus::Failure; + } + + UEnhancedInputLocalPlayerSubsystem* EnhancedInput; + if (!TryGetEnhancedInputSubsystemFromController(PlayerController, EnhancedInput)) + { + return EDirectiveUtilSuccessStatus::Failure; + } + + if (int32 PreviousPriority; EnhancedInput->HasMappingContext(LoadedPreviousMappingContext, PreviousPriority)) + { + const int32 TargetPriority = bUsePreviousPriority ? PreviousPriority : Priority; + EnhancedInput->RemoveMappingContext(LoadedPreviousMappingContext); + EnhancedInput->AddMappingContext(LoadedNewMappingContext, TargetPriority); + UE_LOGFMT(LogDirectiveUtil, Verbose, "Input mapping contexts {PreviousContext} and {NewContext} swapped successfully at priority {Priority}.", LoadedPreviousMappingContext->GetName(), LoadedNewMappingContext->GetName(), TargetPriority); + } + else + { + EnhancedInput->AddMappingContext(LoadedNewMappingContext, Priority); + UE_LOGFMT(LogDirectiveUtil, Warning, "Previous input mapping context {PreviousContext} not found. New context {NewContext} added at priority {BackupPriority}.", LoadedPreviousMappingContext->GetName(), LoadedNewMappingContext->GetName(), Priority); + } + + return EDirectiveUtilSuccessStatus::Success; +} + +UEnhancedInputLocalPlayerSubsystem* UDirectiveUtilInputFunctionLibrary::GetEnhancedInputSubsystem(AController* PlayerController) +{ + UEnhancedInputLocalPlayerSubsystem* EnhancedInput = nullptr; + TryGetEnhancedInputSubsystemFromController(PlayerController, EnhancedInput); + return EnhancedInput; +} + +bool UDirectiveUtilInputFunctionLibrary::IsInputMappingContextActive(AController* PlayerController, TSoftObjectPtr Context) +{ + const UInputMappingContext* MappingContext = Context.LoadSynchronous(); + if (!MappingContext) + { + return false; + } + + UEnhancedInputLocalPlayerSubsystem* EnhancedInput; + if (!TryGetEnhancedInputSubsystemFromController(PlayerController, EnhancedInput)) + { + return false; + } + + int32 OutPriority; + return EnhancedInput->HasMappingContext(MappingContext, OutPriority); +} + +EDirectiveUtilSuccessStatus UDirectiveUtilInputFunctionLibrary::ClearAllInputMappingContexts(AController* PlayerController) +{ + UEnhancedInputLocalPlayerSubsystem* EnhancedInput; + if (!TryGetEnhancedInputSubsystemFromController(PlayerController, EnhancedInput)) + { + return EDirectiveUtilSuccessStatus::Failure; + } + + EnhancedInput->ClearAllMappings(); + return EDirectiveUtilSuccessStatus::Success; +} diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilMapFunctionLibrary.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilMapFunctionLibrary.cpp new file mode 100644 index 00000000..0951666e --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilMapFunctionLibrary.cpp @@ -0,0 +1,205 @@ +// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License. + + +#include "Libraries/DirectiveUtilMapFunctionLibrary.h" + +void UDirectiveUtilMapFunctionLibrary::Map_FindOrAdd(const TMap& TargetMap, const int32& Key, int32& Value) +{ + checkNoEntry(); +} + +void UDirectiveUtilMapFunctionLibrary::Map_ClearValues(const TMap& TargetMap) +{ + checkNoEntry(); +} + +void UDirectiveUtilMapFunctionLibrary::Map_GetKeysByValue(const TMap& TargetMap, const int32& Value, TArray& Keys) +{ + checkNoEntry(); +} + +bool UDirectiveUtilMapFunctionLibrary::Map_HasValue(const TMap& TargetMap, const int32& Value) +{ + checkNoEntry(); + return false; +} + +int32 UDirectiveUtilMapFunctionLibrary::Map_RemoveKeys(const TMap& TargetMap, const TArray& Keys) +{ + checkNoEntry(); + return 0; +} + +void UDirectiveUtilMapFunctionLibrary::Map_Append(const TMap& TargetMap, const TMap& SourceMap, bool bOverwriteExisting) +{ + checkNoEntry(); +} + +void UDirectiveUtilMapFunctionLibrary::GenericMap_FindOrAdd( + const void* TargetMap, + const FMapProperty* MapProperty, + const void* KeyPtr, + void* ValuePtr) +{ + if (!TargetMap || !MapProperty || !KeyPtr) + { + return; + } + + FScriptMapHelper MapHelper(MapProperty, TargetMap); + + void* ValueInMap = MapHelper.FindOrAdd(KeyPtr); + if (ValuePtr && ValueInMap) + { + MapProperty->ValueProp->CopyCompleteValueFromScriptVM(ValuePtr, ValueInMap); + } +} + +void UDirectiveUtilMapFunctionLibrary::GenericMap_ClearValues( + const void* TargetMap, + const FMapProperty* MapProperty) +{ + if (!TargetMap || !MapProperty) + { + return; + } + + FScriptMapHelper MapHelper(MapProperty, TargetMap); + const FProperty* ValueProp = MapProperty->ValueProp; + + const int32 MaxIndex = MapHelper.GetMaxIndex(); + for (int32 InternalIndex = 0; InternalIndex < MaxIndex; ++InternalIndex) + { + if (MapHelper.IsValidIndex(InternalIndex)) + { + ValueProp->ClearValue(MapHelper.GetValuePtr(InternalIndex)); + } + } +} + +void UDirectiveUtilMapFunctionLibrary::GenericMap_GetKeysByValue( + const void* TargetMap, + const FMapProperty* MapProperty, + const void* ValuePtr, + const void* TargetArray, + const FArrayProperty* ArrayProperty) +{ + if (!TargetArray || !ArrayProperty) + { + return; + } + + FScriptArrayHelper ArrayHelper(ArrayProperty, TargetArray); + ArrayHelper.EmptyValues(); + + if (!TargetMap || !MapProperty || !ValuePtr || !ArrayProperty->Inner->SameType(MapProperty->KeyProp)) + { + return; + } + + FScriptMapHelper MapHelper(MapProperty, TargetMap); + const FProperty* ValueProp = MapProperty->ValueProp; + const FProperty* InnerProp = ArrayProperty->Inner; + + const int32 MaxIndex = MapHelper.GetMaxIndex(); + for (int32 InternalIndex = 0; InternalIndex < MaxIndex; ++InternalIndex) + { + if (MapHelper.IsValidIndex(InternalIndex) && ValueProp->Identical(MapHelper.GetValuePtr(InternalIndex), ValuePtr)) + { + const int32 LastIndex = ArrayHelper.AddValue(); + InnerProp->CopySingleValueToScriptVM(ArrayHelper.GetRawPtr(LastIndex), MapHelper.GetKeyPtr(InternalIndex)); + } + } +} + +bool UDirectiveUtilMapFunctionLibrary::GenericMap_HasValue( + const void* TargetMap, + const FMapProperty* MapProperty, + const void* ValuePtr) +{ + if (!TargetMap || !MapProperty || !ValuePtr) + { + return false; + } + + FScriptMapHelper MapHelper(MapProperty, TargetMap); + const FProperty* ValueProp = MapProperty->ValueProp; + + const int32 MaxIndex = MapHelper.GetMaxIndex(); + for (int32 InternalIndex = 0; InternalIndex < MaxIndex; ++InternalIndex) + { + if (MapHelper.IsValidIndex(InternalIndex) && ValueProp->Identical(MapHelper.GetValuePtr(InternalIndex), ValuePtr)) + { + return true; + } + } + + return false; +} + +int32 UDirectiveUtilMapFunctionLibrary::GenericMap_RemoveKeys( + const void* TargetMap, + const FMapProperty* MapProperty, + const void* TargetArray, + const FArrayProperty* ArrayProperty) +{ + if (!TargetMap || !MapProperty || !TargetArray || !ArrayProperty + || !ArrayProperty->Inner->SameType(MapProperty->KeyProp)) + { + return 0; + } + + FScriptMapHelper MapHelper(MapProperty, TargetMap); + FScriptArrayHelper ArrayHelper(ArrayProperty, TargetArray); + + int32 NumRemoved = 0; + const int32 Num = ArrayHelper.Num(); + for (int32 Index = 0; Index < Num; ++Index) + { + if (MapHelper.RemovePair(ArrayHelper.GetRawPtr(Index))) + { + ++NumRemoved; + } + } + + return NumRemoved; +} + +void UDirectiveUtilMapFunctionLibrary::GenericMap_Append( + const void* TargetMap, + const FMapProperty* TargetMapProperty, + const void* SourceMap, + const FMapProperty* SourceMapProperty, + const bool bOverwriteExisting) +{ + if (!TargetMap || !TargetMapProperty || !SourceMap || !SourceMapProperty + || !TargetMapProperty->KeyProp->SameType(SourceMapProperty->KeyProp) + || !TargetMapProperty->ValueProp->SameType(SourceMapProperty->ValueProp)) + { + return; + } + + // Appending a map onto itself is a no-op; bail before AddPair can reallocate under the source pointers. + if (TargetMap == SourceMap) + { + return; + } + + FScriptMapHelper TargetHelper(TargetMapProperty, TargetMap); + FScriptMapHelper SourceHelper(SourceMapProperty, SourceMap); + + const int32 MaxIndex = SourceHelper.GetMaxIndex(); + for (int32 InternalIndex = 0; InternalIndex < MaxIndex; ++InternalIndex) + { + if (!SourceHelper.IsValidIndex(InternalIndex)) + { + continue; + } + + const uint8* KeyPtr = SourceHelper.GetKeyPtr(InternalIndex); + if (bOverwriteExisting || !TargetHelper.FindValueFromHash(KeyPtr)) + { + TargetHelper.AddPair(KeyPtr, SourceHelper.GetValuePtr(InternalIndex)); + } + } +} diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilMathFunctionLibrary.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilMathFunctionLibrary.cpp new file mode 100644 index 00000000..b14c0700 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilMathFunctionLibrary.cpp @@ -0,0 +1,466 @@ +// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License. + + +#include "Libraries/DirectiveUtilMathFunctionLibrary.h" + +namespace +{ + double EaseBackIn(double t) + { + const double s = 1.70158; + return t * t * ((s + 1.0) * t - s); + } + + double EaseBackOut(double t) + { + const double s = 1.70158; + t -= 1.0; + return t * t * ((s + 1.0) * t + s) + 1.0; + } + + double EaseBackInOut(double t) + { + const double s = 1.70158 * 1.525; + t *= 2.0; + if (t < 1.0) + { + return 0.5 * (t * t * ((s + 1.0) * t - s)); + } + t -= 2.0; + return 0.5 * (t * t * ((s + 1.0) * t + s) + 2.0); + } + + double EaseElasticIn(double t) + { + if (t <= 0.0) { return 0.0; } + if (t >= 1.0) { return 1.0; } + const double p = 0.3; + const double s = p / 4.0; + t -= 1.0; + return -(FMath::Pow(2.0, 10.0 * t) * FMath::Sin((t - s) * (2.0 * PI) / p)); + } + + double EaseElasticOut(double t) + { + if (t <= 0.0) { return 0.0; } + if (t >= 1.0) { return 1.0; } + const double p = 0.3; + const double s = p / 4.0; + return FMath::Pow(2.0, -10.0 * t) * FMath::Sin((t - s) * (2.0 * PI) / p) + 1.0; + } + + double EaseElasticInOut(double t) + { + if (t <= 0.0) { return 0.0; } + if (t >= 1.0) { return 1.0; } + const double p = 0.3 * 1.5; + const double s = p / 4.0; + t *= 2.0; + if (t < 1.0) + { + t -= 1.0; + return -0.5 * (FMath::Pow(2.0, 10.0 * t) * FMath::Sin((t - s) * (2.0 * PI) / p)); + } + t -= 1.0; + return FMath::Pow(2.0, -10.0 * t) * FMath::Sin((t - s) * (2.0 * PI) / p) * 0.5 + 1.0; + } + + double EaseBounceOut(double t) + { + const double n1 = 7.5625; + const double d1 = 2.75; + if (t < 1.0 / d1) + { + return n1 * t * t; + } + if (t < 2.0 / d1) + { + t -= 1.5 / d1; + return n1 * t * t + 0.75; + } + if (t < 2.5 / d1) + { + t -= 2.25 / d1; + return n1 * t * t + 0.9375; + } + t -= 2.625 / d1; + return n1 * t * t + 0.984375; + } + + double EaseBounceIn(double t) + { + return 1.0 - EaseBounceOut(1.0 - t); + } + + double EaseBounceInOut(double t) + { + return t < 0.5 + ? (1.0 - EaseBounceOut(1.0 - 2.0 * t)) * 0.5 + : (1.0 + EaseBounceOut(2.0 * t - 1.0)) * 0.5; + } +} + +float UDirectiveUtilMathFunctionLibrary::PerlinNoise2D(const FVector2D Position) +{ + return FMath::PerlinNoise2D(Position); +} + +float UDirectiveUtilMathFunctionLibrary::PerlinNoise3D(const FVector& Position) +{ + return FMath::PerlinNoise3D(Position); +} + +float UDirectiveUtilMathFunctionLibrary::AngleBetweenVectors(const FVector& A, const FVector& B) +{ + return FMath::RadiansToDegrees(FMath::Acos(FMath::Clamp(FVector::DotProduct(A.GetSafeNormal(), B.GetSafeNormal()), -1.0, 1.0))); +} + +float UDirectiveUtilMathFunctionLibrary::EaseAlpha(const float Alpha, const EDirectiveUtilEaseType EaseType) +{ + const double t = static_cast(FMath::Clamp(Alpha, 0.0f, 1.0f)); + double Result = t; + switch (EaseType) + { + case EDirectiveUtilEaseType::BackIn: Result = EaseBackIn(t); break; + case EDirectiveUtilEaseType::BackOut: Result = EaseBackOut(t); break; + case EDirectiveUtilEaseType::BackInOut: Result = EaseBackInOut(t); break; + case EDirectiveUtilEaseType::ElasticIn: Result = EaseElasticIn(t); break; + case EDirectiveUtilEaseType::ElasticOut: Result = EaseElasticOut(t); break; + case EDirectiveUtilEaseType::ElasticInOut: Result = EaseElasticInOut(t); break; + case EDirectiveUtilEaseType::BounceIn: Result = EaseBounceIn(t); break; + case EDirectiveUtilEaseType::BounceOut: Result = EaseBounceOut(t); break; + case EDirectiveUtilEaseType::BounceInOut: Result = EaseBounceInOut(t); break; + } + return static_cast(Result); +} + +float UDirectiveUtilMathFunctionLibrary::EaseFloat(const float A, const float B, const float Alpha, const EDirectiveUtilEaseType EaseType) +{ + return FMath::Lerp(A, B, EaseAlpha(Alpha, EaseType)); +} + +FVector UDirectiveUtilMathFunctionLibrary::EaseVector(const FVector& A, const FVector& B, const float Alpha, const EDirectiveUtilEaseType EaseType) +{ + return FMath::Lerp(A, B, static_cast(EaseAlpha(Alpha, EaseType))); +} + +FRotator UDirectiveUtilMathFunctionLibrary::EaseRotator(const FRotator& A, const FRotator& B, const float Alpha, const EDirectiveUtilEaseType EaseType) +{ + return FQuat::Slerp(A.Quaternion(), B.Quaternion(), EaseAlpha(Alpha, EaseType)).Rotator(); +} + +FLinearColor UDirectiveUtilMathFunctionLibrary::EaseColor(const FLinearColor& A, const FLinearColor& B, const float Alpha, const EDirectiveUtilEaseType EaseType) +{ + return FMath::Lerp(A, B, EaseAlpha(Alpha, EaseType)); +} + +float UDirectiveUtilMathFunctionLibrary::RoundToDecimals(const float Value, int32 Decimals) +{ + Decimals = FMath::Clamp(Decimals, 0, 10); + if (Decimals == 0) + { + return FMath::RoundHalfFromZero(Value); + } + const double Factor = FMath::Pow(10.0, static_cast(Decimals)); + return static_cast(FMath::RoundHalfFromZero(static_cast(Value) * Factor) / Factor); +} + +FText UDirectiveUtilMathFunctionLibrary::RoundToDecimalsAsText(const float Value, int32 Decimals) +{ + Decimals = FMath::Clamp(Decimals, 0, 10); + FNumberFormattingOptions Options; + Options.MinimumFractionalDigits = 0; + Options.MaximumFractionalDigits = Decimals; + Options.RoundingMode = ERoundingMode::HalfFromZero; + return FText::AsNumber(Value, &Options); +} + +FText UDirectiveUtilMathFunctionLibrary::FormatBytes(const int64 Bytes, int32 Decimals) +{ + Decimals = FMath::Clamp(Decimals, 0, 3); + + static const TCHAR* Suffixes[] = { TEXT("B"), TEXT("KB"), TEXT("MB"), TEXT("GB"), TEXT("TB"), TEXT("PB") }; + const bool bNegative = Bytes < 0; + double Value = FMath::Abs(static_cast(Bytes)); + int32 SuffixIndex = 0; + while (Value >= 1024.0 && SuffixIndex < UE_ARRAY_COUNT(Suffixes) - 1) + { + Value /= 1024.0; + ++SuffixIndex; + } + + return FText::FromString(FString::Printf(TEXT("%s%.*f %s"), + bNegative ? TEXT("-") : TEXT(""), SuffixIndex == 0 ? 0 : Decimals, Value, Suffixes[SuffixIndex])); +} + +FText UDirectiveUtilMathFunctionLibrary::FormatDuration(const float Seconds, const bool bIncludeSeconds) +{ + if (!FMath::IsFinite(Seconds)) + { + return FText::FromString(TEXT("0s")); + } + + const int64 TotalSeconds = static_cast(FMath::Abs(static_cast(Seconds))); + const bool bNegative = Seconds < 0.0f && TotalSeconds > 0; + + const int64 UnitValues[] = { TotalSeconds / 86400, (TotalSeconds / 3600) % 24, (TotalSeconds / 60) % 60, TotalSeconds % 60 }; + static const TCHAR* UnitSuffixes[] = { TEXT("d"), TEXT("h"), TEXT("m"), TEXT("s") }; + const int32 NumUnits = bIncludeSeconds ? 4 : 3; + + int32 FirstUnit = NumUnits - 1; + for (int32 Index = 0; Index < NumUnits; ++Index) + { + if (UnitValues[Index] != 0) + { + FirstUnit = Index; + break; + } + } + int32 LastUnit = FirstUnit; + for (int32 Index = NumUnits - 1; Index >= FirstUnit; --Index) + { + if (UnitValues[Index] != 0) + { + LastUnit = Index; + break; + } + } + + FString Result = bNegative ? TEXT("-") : TEXT(""); + for (int32 Index = FirstUnit; Index <= LastUnit; ++Index) + { + if (Index == FirstUnit) + { + Result += FString::Printf(TEXT("%lld%s"), UnitValues[Index], UnitSuffixes[Index]); + } + else + { + Result += FString::Printf(TEXT(" %02lld%s"), UnitValues[Index], UnitSuffixes[Index]); + } + } + return FText::FromString(Result); +} + +FText UDirectiveUtilMathFunctionLibrary::FormatRelativeTime(const FDateTime& Timestamp) +{ + const FTimespan Delta = FDateTime::Now() - Timestamp; + const bool bFuture = Delta.GetTicks() < 0; + // Round to whole seconds so clock-adjacent inputs (e.g. Now() + 2 hours) land in the intended bucket. + const int64 SecondsAbs = static_cast(FMath::RoundToDouble(FMath::Abs(Delta.GetTotalSeconds()))); + + if (SecondsAbs < 60) + { + return FText::FromString(TEXT("just now")); + } + + int64 Count; + const TCHAR* Unit; + if (SecondsAbs < 3600) + { + Count = SecondsAbs / 60; + Unit = TEXT("minute"); + } + else if (SecondsAbs < 86400) + { + Count = SecondsAbs / 3600; + Unit = TEXT("hour"); + } + else + { + Count = SecondsAbs / 86400; + Unit = TEXT("day"); + } + + const FString Quantity = FString::Printf(TEXT("%lld %s%s"), Count, Unit, Count == 1 ? TEXT("") : TEXT("s")); + return FText::FromString(bFuture + ? FString::Printf(TEXT("in %s"), *Quantity) + : FString::Printf(TEXT("%s ago"), *Quantity)); +} + +int64 UDirectiveUtilMathFunctionLibrary::GetIntArraySum(const TArray& Values) +{ + int64 Sum = 0; + for (const int32 Value : Values) + { + Sum += Value; + } + return Sum; +} + +float UDirectiveUtilMathFunctionLibrary::GetIntArrayAverage(const TArray& Values) +{ + if (Values.IsEmpty()) + { + return 0.0f; + } + return static_cast(static_cast(GetIntArraySum(Values)) / Values.Num()); +} + +float UDirectiveUtilMathFunctionLibrary::GetIntArrayMedian(const TArray& Values) +{ + if (Values.IsEmpty()) + { + return 0.0f; + } + + TArray Sorted = Values; + Sorted.Sort(); + + const int32 Middle = Sorted.Num() / 2; + if (Sorted.Num() % 2 == 0) + { + return static_cast((static_cast(Sorted[Middle - 1]) + static_cast(Sorted[Middle])) * 0.5); + } + return static_cast(Sorted[Middle]); +} + +float UDirectiveUtilMathFunctionLibrary::GetIntArrayStandardDeviation(const TArray& Values) +{ + if (Values.IsEmpty()) + { + return 0.0f; + } + + const double Mean = static_cast(GetIntArraySum(Values)) / Values.Num(); + double SquaredDeltaSum = 0.0; + for (const int32 Value : Values) + { + const double Delta = static_cast(Value) - Mean; + SquaredDeltaSum += Delta * Delta; + } + return static_cast(FMath::Sqrt(SquaredDeltaSum / Values.Num())); +} + +float UDirectiveUtilMathFunctionLibrary::GetFloatArraySum(const TArray& Values) +{ + double Sum = 0.0; + for (const float Value : Values) + { + Sum += static_cast(Value); + } + return static_cast(Sum); +} + +float UDirectiveUtilMathFunctionLibrary::GetFloatArrayAverage(const TArray& Values) +{ + if (Values.IsEmpty()) + { + return 0.0f; + } + + double Sum = 0.0; + for (const float Value : Values) + { + Sum += static_cast(Value); + } + return static_cast(Sum / Values.Num()); +} + +float UDirectiveUtilMathFunctionLibrary::GetFloatArrayMedian(const TArray& Values) +{ + if (Values.IsEmpty()) + { + return 0.0f; + } + + TArray Sorted = Values; + Sorted.Sort(); + + const int32 Middle = Sorted.Num() / 2; + if (Sorted.Num() % 2 == 0) + { + return static_cast((static_cast(Sorted[Middle - 1]) + static_cast(Sorted[Middle])) * 0.5); + } + return Sorted[Middle]; +} + +float UDirectiveUtilMathFunctionLibrary::GetFloatArrayStandardDeviation(const TArray& Values) +{ + if (Values.IsEmpty()) + { + return 0.0f; + } + + double Sum = 0.0; + for (const float Value : Values) + { + Sum += static_cast(Value); + } + const double Mean = Sum / Values.Num(); + + double SquaredDeltaSum = 0.0; + for (const float Value : Values) + { + const double Delta = static_cast(Value) - Mean; + SquaredDeltaSum += Delta * Delta; + } + return static_cast(FMath::Sqrt(SquaredDeltaSum / Values.Num())); +} + +int32 UDirectiveUtilMathFunctionLibrary::GetRandomIndexFromWeights(const TArray& Weights) +{ + float Total = 0.0f; + for (const float Weight : Weights) + { + Total += FMath::Max(0.0f, Weight); + } + + if (Total <= 0.0f) + { + return INDEX_NONE; + } + + const float Roll = FMath::FRand() * Total; + float Accumulated = 0.0f; + int32 LastPositiveIndex = INDEX_NONE; + for (int32 Index = 0; Index < Weights.Num(); ++Index) + { + const float Weight = FMath::Max(0.0f, Weights[Index]); + if (Weight <= 0.0f) + { + continue; + } + LastPositiveIndex = Index; + Accumulated += Weight; + if (Roll < Accumulated) + { + return Index; + } + } + + return LastPositiveIndex; +} + +int32 UDirectiveUtilMathFunctionLibrary::GetRandomIndexFromWeightsFromStream(FRandomStream& Stream, const TArray& Weights) +{ + float Total = 0.0f; + for (const float Weight : Weights) + { + Total += FMath::Max(0.0f, Weight); + } + + if (Total <= 0.0f) + { + return INDEX_NONE; + } + + const float Roll = Stream.FRand() * Total; + float Accumulated = 0.0f; + int32 LastPositiveIndex = INDEX_NONE; + for (int32 Index = 0; Index < Weights.Num(); ++Index) + { + const float Weight = FMath::Max(0.0f, Weights[Index]); + if (Weight <= 0.0f) + { + continue; + } + LastPositiveIndex = Index; + Accumulated += Weight; + if (Roll < Accumulated) + { + return Index; + } + } + + return LastPositiveIndex; +} diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilRegexFunctionLibrary.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilRegexFunctionLibrary.cpp new file mode 100644 index 00000000..efe52d48 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilRegexFunctionLibrary.cpp @@ -0,0 +1,131 @@ +// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License. + + +#include "Libraries/DirectiveUtilRegexFunctionLibrary.h" +#include "Internationalization/Regex.h" + +namespace +{ + FRegexPattern MakePattern(const FString& Pattern, const bool bCaseSensitive) + { + return FRegexPattern(Pattern, bCaseSensitive ? ERegexPatternFlags::None : ERegexPatternFlags::CaseInsensitive); + } +} + +bool UDirectiveUtilRegexFunctionLibrary::RegexMatches(const FString& Input, const FString& Pattern, const bool bCaseSensitive) +{ + if (Pattern.IsEmpty()) + { + return false; + } + + const FRegexPattern RegexPattern = MakePattern(Pattern, bCaseSensitive); + FRegexMatcher Matcher(RegexPattern, Input); + return Matcher.FindNext(); +} + +bool UDirectiveUtilRegexFunctionLibrary::RegexFindFirst(const FString& Input, const FString& Pattern, FString& OutMatch, int32& OutMatchStart, int32& OutMatchEnd, const bool bCaseSensitive) +{ + OutMatch = FString(); + OutMatchStart = INDEX_NONE; + OutMatchEnd = INDEX_NONE; + + if (Pattern.IsEmpty()) + { + return false; + } + + const FRegexPattern RegexPattern = MakePattern(Pattern, bCaseSensitive); + FRegexMatcher Matcher(RegexPattern, Input); + if (Matcher.FindNext()) + { + OutMatchStart = Matcher.GetMatchBeginning(); + OutMatchEnd = Matcher.GetMatchEnding(); + OutMatch = Matcher.GetCaptureGroup(0); + return true; + } + return false; +} + +TArray UDirectiveUtilRegexFunctionLibrary::RegexFindAll(const FString& Input, const FString& Pattern, const bool bCaseSensitive) +{ + TArray Matches; + if (Pattern.IsEmpty()) + { + return Matches; + } + + const FRegexPattern RegexPattern = MakePattern(Pattern, bCaseSensitive); + FRegexMatcher Matcher(RegexPattern, Input); + while (Matcher.FindNext()) + { + const int32 Begin = Matcher.GetMatchBeginning(); + const int32 End = Matcher.GetMatchEnding(); + if (Begin == End) + { + continue; + } + Matches.Add(Matcher.GetCaptureGroup(0)); + } + return Matches; +} + +FString UDirectiveUtilRegexFunctionLibrary::RegexReplaceAll(const FString& Input, const FString& Pattern, const FString& Replacement, const bool bCaseSensitive) +{ + if (Pattern.IsEmpty()) + { + return Input; + } + + const FRegexPattern RegexPattern = MakePattern(Pattern, bCaseSensitive); + FRegexMatcher Matcher(RegexPattern, Input); + + FString Result; + int32 LastEnd = 0; + while (Matcher.FindNext()) + { + const int32 Begin = Matcher.GetMatchBeginning(); + const int32 End = Matcher.GetMatchEnding(); + + if (Begin == End) + { + continue; + } + + if (Begin < LastEnd) + { + continue; + } + + Result.Append(Input.Mid(LastEnd, Begin - LastEnd)); + Result.Append(Replacement); + LastEnd = End; + } + Result.Append(Input.Mid(LastEnd)); + return Result; +} + +bool UDirectiveUtilRegexFunctionLibrary::RegexGetCaptureGroup(const FString& Input, const FString& Pattern, const int32 GroupIndex, FString& OutGroup, const bool bCaseSensitive) +{ + OutGroup = FString(); + + if (Pattern.IsEmpty() || GroupIndex < 0) + { + return false; + } + + const FRegexPattern RegexPattern = MakePattern(Pattern, bCaseSensitive); + FRegexMatcher Matcher(RegexPattern, Input); + if (!Matcher.FindNext()) + { + return false; + } + + if (Matcher.GetCaptureGroupBeginning(GroupIndex) == INDEX_NONE) + { + return false; + } + + OutGroup = Matcher.GetCaptureGroup(GroupIndex); + return true; +} diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilSaveGameFunctionLibrary.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilSaveGameFunctionLibrary.cpp new file mode 100644 index 00000000..1b3fdbfa --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilSaveGameFunctionLibrary.cpp @@ -0,0 +1,121 @@ +// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License. + + +#include "Libraries/DirectiveUtilSaveGameFunctionLibrary.h" +#include "Libraries/DirectiveUtilStringFunctionLibrary.h" +#include "Kismet/GameplayStatics.h" +#include "GameFramework/SaveGame.h" +#include "HAL/FileManager.h" +#include "Misc/Paths.h" + +namespace +{ + FString GetSaveGamesDirectory() + { + return FPaths::ProjectSavedDir() / TEXT("SaveGames"); + } + + FString GetSaveSlotFilePath(const FString& SlotName) + { + return GetSaveGamesDirectory() / (SlotName + TEXT(".sav")); + } + + bool IsValidSaveSlotName(const FString& SlotName) + { + return UDirectiveUtilStringFunctionLibrary::IsValidFileName(SlotName); + } +} + +TArray UDirectiveUtilSaveGameFunctionLibrary::GetAllSaveSlotNames() +{ + TArray SlotNames; + + TArray Files; + IFileManager::Get().FindFiles(Files, *(GetSaveGamesDirectory() / TEXT("*.sav")), true, false); + + SlotNames.Reserve(Files.Num()); + for (const FString& File : Files) + { + SlotNames.Add(FPaths::GetBaseFilename(File)); + } + return SlotNames; +} + +bool UDirectiveUtilSaveGameFunctionLibrary::GetSaveSlotTimestamp(const FString& SlotName, FDateTime& OutTimestamp) +{ + OutTimestamp = FDateTime(); + if (!IsValidSaveSlotName(SlotName)) + { + return false; + } + + const FDateTime Timestamp = IFileManager::Get().GetTimeStamp(*GetSaveSlotFilePath(SlotName)); + if (Timestamp == FDateTime::MinValue()) + { + return false; + } + + OutTimestamp = Timestamp + (FDateTime::Now() - FDateTime::UtcNow()); + return true; +} + +bool UDirectiveUtilSaveGameFunctionLibrary::SaveGameToBytes(USaveGame* SaveGameObject, TArray& OutBytes) +{ + OutBytes.Reset(); + if (!SaveGameObject) + { + return false; + } + return UGameplayStatics::SaveGameToMemory(SaveGameObject, OutBytes); +} + +USaveGame* UDirectiveUtilSaveGameFunctionLibrary::LoadGameFromBytes(const TArray& SaveData) +{ + if (SaveData.Num() == 0) + { + return nullptr; + } + return UGameplayStatics::LoadGameFromMemory(SaveData); +} + +bool UDirectiveUtilSaveGameFunctionLibrary::DoesSaveSlotExist(const FString& SlotName, const int32 UserIndex) +{ + if (!IsValidSaveSlotName(SlotName)) + { + return false; + } + return UGameplayStatics::DoesSaveGameExist(SlotName, UserIndex); +} + +bool UDirectiveUtilSaveGameFunctionLibrary::DeleteSaveSlot(const FString& SlotName, const int32 UserIndex) +{ + if (!IsValidSaveSlotName(SlotName)) + { + return false; + } + return UGameplayStatics::DeleteGameInSlot(SlotName, UserIndex); +} + +bool UDirectiveUtilSaveGameFunctionLibrary::RenameSaveSlot(const FString& OldSlotName, const FString& NewSlotName, const int32 UserIndex) +{ + if (!IsValidSaveSlotName(OldSlotName) || !IsValidSaveSlotName(NewSlotName) || OldSlotName == NewSlotName) + { + return false; + } + if (!UGameplayStatics::DoesSaveGameExist(OldSlotName, UserIndex) || UGameplayStatics::DoesSaveGameExist(NewSlotName, UserIndex)) + { + return false; + } + + TArray SaveData; + if (!UGameplayStatics::LoadDataFromSlot(SaveData, OldSlotName, UserIndex)) + { + return false; + } + if (!UGameplayStatics::SaveDataToSlot(SaveData, NewSlotName, UserIndex)) + { + return false; + } + // If this delete fails the new copy is kept alongside the original, so the save is never lost. + return UGameplayStatics::DeleteGameInSlot(OldSlotName, UserIndex); +} diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilStringFunctionLibrary.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilStringFunctionLibrary.cpp new file mode 100644 index 00000000..f4af8ade --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilStringFunctionLibrary.cpp @@ -0,0 +1,422 @@ +// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License. + + +#include "Libraries/DirectiveUtilStringFunctionLibrary.h" +#include "Misc/Base64.h" +#include "Misc/Crc.h" +#include "Misc/Paths.h" +#include "Misc/SecureHash.h" + +namespace +{ + TArray StringToUtf8Bytes(const FString& String) + { + const FTCHARToUTF8 Converter(*String, String.Len()); + return TArray(reinterpret_cast(Converter.Get()), Converter.Length()); + } +} + +bool UDirectiveUtilStringFunctionLibrary::ContainsLetters(const FString& String) +{ + for (const TCHAR& Char : String) + { + if (FChar::IsAlpha(Char)) + { + return true; + } + } + return false; +} + +bool UDirectiveUtilStringFunctionLibrary::ContainsNumbers(const FString& String) +{ + for (const TCHAR& Char : String) + { + if (FChar::IsDigit(Char)) + { + return true; + } + } + return false; +} + +bool UDirectiveUtilStringFunctionLibrary::ContainsSpaces(const FString& String) +{ + for (const TCHAR& Char : String) + { + if (FChar::IsWhitespace(Char)) + { + return true; + } + } + return false; +} + +bool UDirectiveUtilStringFunctionLibrary::ContainsSpecialCharacters(const FString& String) +{ + for (const TCHAR& Char : String) + { + if (FChar::IsPunct(Char)) + { + return true; + } + } + return false; +} + +FString UDirectiveUtilStringFunctionLibrary::FilterCharacters( + const FString& String, + const bool bLetters, + const bool bNumbers, + const bool bSpecialCharacters, + const bool bSpaces) +{ + FString NewString; + NewString.Reserve(String.Len()); + + for (const TCHAR& Char : String) + { + if (bLetters && FChar::IsAlpha(Char)) continue; + if (bNumbers && FChar::IsDigit(Char)) continue; + if (bSpecialCharacters && FChar::IsPunct(Char)) continue; + if (bSpaces && FChar::IsWhitespace(Char)) continue; + NewString.AppendChar(Char); + } + return NewString; +} + +FString UDirectiveUtilStringFunctionLibrary::TruncateString(const FString& String, const int32 MaxLength, const FString& Suffix) +{ + if (String.Len() <= MaxLength) + { + return String; + } + return String.Left(FMath::Max(0, MaxLength - Suffix.Len())) + Suffix; +} + +FString UDirectiveUtilStringFunctionLibrary::ToTitleCase(const FString& String) +{ + FString Result; + Result.Reserve(String.Len()); + bool bCapitalizeNext = true; + + for (const TCHAR& Char : String) + { + if (FChar::IsWhitespace(Char)) + { + bCapitalizeNext = true; + Result.AppendChar(Char); + } + else if (bCapitalizeNext && FChar::IsAlpha(Char)) + { + Result.AppendChar(FChar::ToUpper(Char)); + bCapitalizeNext = false; + } + else + { + Result.AppendChar(FChar::ToLower(Char)); + bCapitalizeNext = false; + } + } + + return Result; +} + +TArray UDirectiveUtilStringFunctionLibrary::SplitIntoWords(const FString& String) +{ + TArray Words; + FString CurrentWord; + + for (int32 Index = 0; Index < String.Len(); ++Index) + { + const TCHAR Char = String[Index]; + if (!FChar::IsAlnum(Char)) + { + if (!CurrentWord.IsEmpty()) + { + Words.Add(MoveTemp(CurrentWord)); + CurrentWord.Reset(); + } + continue; + } + + if (!CurrentWord.IsEmpty()) + { + const TCHAR Previous = CurrentWord[CurrentWord.Len() - 1]; + const bool bNextIsLower = Index + 1 < String.Len() && FChar::IsLower(String[Index + 1]); + const bool bBoundary = + (FChar::IsLower(Previous) && FChar::IsUpper(Char)) || + (FChar::IsUpper(Previous) && FChar::IsUpper(Char) && bNextIsLower) || + (FChar::IsAlpha(Previous) && FChar::IsDigit(Char)) || + (FChar::IsDigit(Previous) && FChar::IsAlpha(Char)); + if (bBoundary) + { + Words.Add(MoveTemp(CurrentWord)); + CurrentWord.Reset(); + } + } + + CurrentWord.AppendChar(Char); + } + + if (!CurrentWord.IsEmpty()) + { + Words.Add(MoveTemp(CurrentWord)); + } + return Words; +} + +FString UDirectiveUtilStringFunctionLibrary::ToCamelCase(const FString& String) +{ + const TArray Words = SplitIntoWords(String); + FString Result; + for (int32 Index = 0; Index < Words.Num(); ++Index) + { + FString Word = Words[Index].ToLower(); + if (Index > 0) + { + Word[0] = FChar::ToUpper(Word[0]); + } + Result += Word; + } + return Result; +} + +FString UDirectiveUtilStringFunctionLibrary::ToPascalCase(const FString& String) +{ + FString Result; + for (const FString& Word : SplitIntoWords(String)) + { + FString Cased = Word.ToLower(); + Cased[0] = FChar::ToUpper(Cased[0]); + Result += Cased; + } + return Result; +} + +FString UDirectiveUtilStringFunctionLibrary::ToSnakeCase(const FString& String) +{ + return FString::Join(SplitIntoWords(String), TEXT("_")).ToLower(); +} + +FString UDirectiveUtilStringFunctionLibrary::ToKebabCase(const FString& String) +{ + return FString::Join(SplitIntoWords(String), TEXT("-")).ToLower(); +} + +TArray UDirectiveUtilStringFunctionLibrary::SortStringArray(TArray StringArray) +{ + Algo::Sort(StringArray); + return StringArray; +} + +TArray UDirectiveUtilStringFunctionLibrary::GetSortedStringArray(const TArray StringArray) +{ + TArray SortedArray = StringArray; + Algo::Sort(SortedArray); + return SortedArray; +} + +int32 UDirectiveUtilStringFunctionLibrary::GetLevenshteinDistance(const FString& A, const FString& B, const bool bCaseSensitive) +{ + const FString StringA = bCaseSensitive ? A : A.ToLower(); + const FString StringB = bCaseSensitive ? B : B.ToLower(); + const int32 LenA = StringA.Len(); + const int32 LenB = StringB.Len(); + + if (LenA == 0) { return LenB; } + if (LenB == 0) { return LenA; } + + TArray PreviousRow; + TArray CurrentRow; + PreviousRow.SetNumUninitialized(LenB + 1); + CurrentRow.SetNumUninitialized(LenB + 1); + + for (int32 ColumnIndex = 0; ColumnIndex <= LenB; ++ColumnIndex) + { + PreviousRow[ColumnIndex] = ColumnIndex; + } + + for (int32 RowIndex = 1; RowIndex <= LenA; ++RowIndex) + { + CurrentRow[0] = RowIndex; + for (int32 ColumnIndex = 1; ColumnIndex <= LenB; ++ColumnIndex) + { + const int32 SubstitutionCost = (StringA[RowIndex - 1] == StringB[ColumnIndex - 1]) ? 0 : 1; + CurrentRow[ColumnIndex] = FMath::Min3( + PreviousRow[ColumnIndex] + 1, + CurrentRow[ColumnIndex - 1] + 1, + PreviousRow[ColumnIndex - 1] + SubstitutionCost); + } + Exchange(PreviousRow, CurrentRow); + } + + return PreviousRow[LenB]; +} + +float UDirectiveUtilStringFunctionLibrary::GetStringSimilarity(const FString& A, const FString& B, const bool bCaseSensitive) +{ + const int32 MaxLength = FMath::Max(A.Len(), B.Len()); + if (MaxLength == 0) + { + return 1.0f; + } + const int32 Distance = GetLevenshteinDistance(A, B, bCaseSensitive); + return 1.0f - (static_cast(Distance) / static_cast(MaxLength)); +} + +bool UDirectiveUtilStringFunctionLibrary::ContainsAny(const FString& Source, const TArray& SearchTerms, const bool bCaseSensitive) +{ + const ESearchCase::Type SearchCase = bCaseSensitive ? ESearchCase::CaseSensitive : ESearchCase::IgnoreCase; + for (const FString& Term : SearchTerms) + { + if (!Term.IsEmpty() && Source.Contains(Term, SearchCase)) + { + return true; + } + } + return false; +} + +bool UDirectiveUtilStringFunctionLibrary::FindFirstOfAny(const FString& Source, const TArray& SearchTerms, const bool bCaseSensitive, int32& OutFoundIndex, int32& OutTermIndex) +{ + OutFoundIndex = INDEX_NONE; + OutTermIndex = INDEX_NONE; + + const ESearchCase::Type SearchCase = bCaseSensitive ? ESearchCase::CaseSensitive : ESearchCase::IgnoreCase; + for (int32 TermIndex = 0; TermIndex < SearchTerms.Num(); ++TermIndex) + { + const FString& Term = SearchTerms[TermIndex]; + if (Term.IsEmpty()) + { + continue; + } + + const int32 FoundIndex = Source.Find(Term, SearchCase, ESearchDir::FromStart); + if (FoundIndex != INDEX_NONE && (OutFoundIndex == INDEX_NONE || FoundIndex < OutFoundIndex)) + { + OutFoundIndex = FoundIndex; + OutTermIndex = TermIndex; + } + } + + return OutFoundIndex != INDEX_NONE; +} + +FString UDirectiveUtilStringFunctionLibrary::Base64Encode(const FString& Source) +{ + return FBase64::Encode(Source); +} + +bool UDirectiveUtilStringFunctionLibrary::Base64Decode(const FString& Source, FString& OutDecoded) +{ + OutDecoded.Reset(); + return FBase64::Decode(Source, OutDecoded); +} + +FString UDirectiveUtilStringFunctionLibrary::HexEncode(const FString& String) +{ + return HexEncodeBytes(StringToUtf8Bytes(String)); +} + +bool UDirectiveUtilStringFunctionLibrary::HexDecode(const FString& Hex, FString& OutString) +{ + OutString.Reset(); + + TArray Bytes; + if (!HexDecodeBytes(Hex, Bytes)) + { + return false; + } + + const FUTF8ToTCHAR Converter(reinterpret_cast(Bytes.GetData()), Bytes.Num()); + OutString = FString(Converter.Length(), Converter.Get()); + return true; +} + +FString UDirectiveUtilStringFunctionLibrary::HexEncodeBytes(const TArray& Bytes) +{ + return BytesToHexLower(Bytes.GetData(), Bytes.Num()); +} + +bool UDirectiveUtilStringFunctionLibrary::HexDecodeBytes(const FString& Hex, TArray& OutBytes) +{ + OutBytes.Reset(); + if (Hex.Len() % 2 != 0) + { + return false; + } + + OutBytes.Reserve(Hex.Len() / 2); + for (int32 Index = 0; Index < Hex.Len(); Index += 2) + { + const TCHAR High = Hex[Index]; + const TCHAR Low = Hex[Index + 1]; + if (!CheckTCharIsHex(High) || !CheckTCharIsHex(Low)) + { + OutBytes.Reset(); + return false; + } + OutBytes.Add(static_cast((TCharToNibble(High) << 4) | TCharToNibble(Low))); + } + return true; +} + +FString UDirectiveUtilStringFunctionLibrary::Md5HashString(const FString& String) +{ + return Md5HashBytes(StringToUtf8Bytes(String)); +} + +FString UDirectiveUtilStringFunctionLibrary::Md5HashBytes(const TArray& Bytes) +{ + return FMD5::HashBytes(Bytes.GetData(), Bytes.Num()); +} + +FString UDirectiveUtilStringFunctionLibrary::Sha1HashString(const FString& String) +{ + return Sha1HashBytes(StringToUtf8Bytes(String)); +} + +FString UDirectiveUtilStringFunctionLibrary::Sha1HashBytes(const TArray& Bytes) +{ + uint8 Digest[20]; + FSHA1::HashBuffer(Bytes.GetData(), Bytes.Num(), Digest); + return BytesToHexLower(Digest, UE_ARRAY_COUNT(Digest)); +} + +int32 UDirectiveUtilStringFunctionLibrary::Crc32String(const FString& String) +{ + return Crc32Bytes(StringToUtf8Bytes(String)); +} + +int32 UDirectiveUtilStringFunctionLibrary::Crc32Bytes(const TArray& Bytes) +{ + return static_cast(FCrc::MemCrc32(Bytes.GetData(), Bytes.Num())); +} + +bool UDirectiveUtilStringFunctionLibrary::IsValidFileName(const FString& String) +{ + return !String.IsEmpty() && FPaths::GetCleanFilename(String) == String && SanitizeFileName(String) == String; +} + +FString UDirectiveUtilStringFunctionLibrary::SanitizeFileName(const FString& String, const FString& Replacement) +{ + return FPaths::MakeValidFileName(String, Replacement.IsEmpty() ? TEXT('\0') : Replacement[0]); +} + +int32 UDirectiveUtilStringFunctionLibrary::FindBestStringMatch(const FString& Input, const TArray& Candidates, float& OutSimilarity, const bool bCaseSensitive) +{ + OutSimilarity = 0.0f; + int32 BestIndex = INDEX_NONE; + + for (int32 Index = 0; Index < Candidates.Num(); ++Index) + { + const float Similarity = GetStringSimilarity(Input, Candidates[Index], bCaseSensitive); + if (BestIndex == INDEX_NONE || Similarity > OutSimilarity) + { + BestIndex = Index; + OutSimilarity = Similarity; + } + } + return BestIndex; +} diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilTextFunctionLibrary.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilTextFunctionLibrary.cpp new file mode 100644 index 00000000..0d0a5b0b --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilTextFunctionLibrary.cpp @@ -0,0 +1,9 @@ +// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License. + + +#include "Libraries/DirectiveUtilTextFunctionLibrary.h" + +bool UDirectiveUtilTextFunctionLibrary::IsNotEmpty(const FText& Text) +{ + return !Text.IsEmpty(); +} diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Tasks/DirectiveUtilTask_AsyncLoadAsset.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Tasks/DirectiveUtilTask_AsyncLoadAsset.cpp new file mode 100644 index 00000000..fd7d8d5f --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Tasks/DirectiveUtilTask_AsyncLoadAsset.cpp @@ -0,0 +1,245 @@ +// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License. + + +#include "Tasks/DirectiveUtilTask_AsyncLoadAsset.h" +#include "DirectiveUtilLogChannels.h" +#include "Engine/AssetManager.h" +#include "Engine/StreamableManager.h" + +UDirectiveUtilTask_AsyncLoadAsset* UDirectiveUtilTask_AsyncLoadAsset::AsyncLoadAsset(UObject* WorldContextObject, const TSoftObjectPtr Asset) +{ + UDirectiveUtilTask_AsyncLoadAsset* Action = NewObject(); + Action->SoftAsset = Asset; + + if (WorldContextObject) + { + Action->RegisterWithGameInstance(WorldContextObject); + } + + return Action; +} + +void UDirectiveUtilTask_AsyncLoadAsset::Activate() +{ + if (SoftAsset.IsNull()) + { + UE_LOG(LogDirectiveUtil, Warning, TEXT("Async Load Asset failed to activate. The soft object reference is null.")); + Failed.Broadcast(nullptr); + SetReadyToDestroy(); + return; + } + + if (!UAssetManager::GetIfInitialized()) + { + UE_LOG(LogDirectiveUtil, Warning, TEXT("Async Load Asset failed to activate. The Asset Manager is not initialized.")); + Failed.Broadcast(nullptr); + SetReadyToDestroy(); + return; + } + + StreamableHandle = UAssetManager::GetStreamableManager().RequestAsyncLoad( + SoftAsset.ToSoftObjectPath(), + FStreamableDelegate::CreateUObject(this, &UDirectiveUtilTask_AsyncLoadAsset::OnLoaded), + FStreamableManager::DefaultAsyncLoadPriority); + + if (!StreamableHandle.IsValid()) + { + UE_LOG(LogDirectiveUtil, Warning, TEXT("Async Load Asset failed to start the load request.")); + Failed.Broadcast(nullptr); + SetReadyToDestroy(); + } +} + +void UDirectiveUtilTask_AsyncLoadAsset::OnLoaded() +{ + UObject* LoadedAsset = StreamableHandle.IsValid() ? StreamableHandle->GetLoadedAsset() : nullptr; + if (LoadedAsset) + { + Completed.Broadcast(LoadedAsset); + } + else + { + UE_LOG(LogDirectiveUtil, Warning, TEXT("Async Load Asset completed but the asset could not be resolved.")); + Failed.Broadcast(nullptr); + } + SetReadyToDestroy(); +} + +void UDirectiveUtilTask_AsyncLoadAsset::Cancel() +{ + if (StreamableHandle.IsValid() && StreamableHandle->IsActive()) + { + StreamableHandle->CancelHandle(); + } + SetReadyToDestroy(); +} + +UDirectiveUtilTask_AsyncLoadClass* UDirectiveUtilTask_AsyncLoadClass::AsyncLoadClass(UObject* WorldContextObject, const TSoftClassPtr AssetClass) +{ + UDirectiveUtilTask_AsyncLoadClass* Action = NewObject(); + Action->SoftClass = AssetClass; + + if (WorldContextObject) + { + Action->RegisterWithGameInstance(WorldContextObject); + } + + return Action; +} + +void UDirectiveUtilTask_AsyncLoadClass::Activate() +{ + if (SoftClass.IsNull()) + { + UE_LOG(LogDirectiveUtil, Warning, TEXT("Async Load Class failed to activate. The soft class reference is null.")); + Failed.Broadcast(nullptr); + SetReadyToDestroy(); + return; + } + + if (!UAssetManager::GetIfInitialized()) + { + UE_LOG(LogDirectiveUtil, Warning, TEXT("Async Load Class failed to activate. The Asset Manager is not initialized.")); + Failed.Broadcast(nullptr); + SetReadyToDestroy(); + return; + } + + StreamableHandle = UAssetManager::GetStreamableManager().RequestAsyncLoad( + SoftClass.ToSoftObjectPath(), + FStreamableDelegate::CreateUObject(this, &UDirectiveUtilTask_AsyncLoadClass::OnLoaded), + FStreamableManager::DefaultAsyncLoadPriority); + + if (!StreamableHandle.IsValid()) + { + UE_LOG(LogDirectiveUtil, Warning, TEXT("Async Load Class failed to start the load request.")); + Failed.Broadcast(nullptr); + SetReadyToDestroy(); + } +} + +void UDirectiveUtilTask_AsyncLoadClass::OnLoaded() +{ + UClass* LoadedClass = StreamableHandle.IsValid() ? Cast(StreamableHandle->GetLoadedAsset()) : nullptr; + if (LoadedClass) + { + Completed.Broadcast(LoadedClass); + } + else + { + UE_LOG(LogDirectiveUtil, Warning, TEXT("Async Load Class completed but the class could not be resolved.")); + Failed.Broadcast(nullptr); + } + SetReadyToDestroy(); +} + +void UDirectiveUtilTask_AsyncLoadClass::Cancel() +{ + if (StreamableHandle.IsValid() && StreamableHandle->IsActive()) + { + StreamableHandle->CancelHandle(); + } + SetReadyToDestroy(); +} + +UDirectiveUtilTask_AsyncLoadAssets* UDirectiveUtilTask_AsyncLoadAssets::AsyncLoadAssets(UObject* WorldContextObject, const TArray>& Assets) +{ + UDirectiveUtilTask_AsyncLoadAssets* Action = NewObject(); + Action->SoftAssets = Assets; + + if (WorldContextObject) + { + Action->RegisterWithGameInstance(WorldContextObject); + } + + return Action; +} + +void UDirectiveUtilTask_AsyncLoadAssets::Activate() +{ + // Unset references are filtered out of the request but keep their null slots in the output; + // duplicates are requested once and resolved per slot. + TArray PathsToLoad; + for (const TSoftObjectPtr& SoftAsset : SoftAssets) + { + if (!SoftAsset.IsNull()) + { + PathsToLoad.AddUnique(SoftAsset.ToSoftObjectPath()); + } + } + + if (PathsToLoad.Num() == 0) + { + // Nothing to request; an empty request list is an error path in the streamable manager. + OnLoaded(); + return; + } + + if (!UAssetManager::GetIfInitialized()) + { + UE_LOG(LogDirectiveUtil, Warning, TEXT("Async Load Assets failed to activate. The Asset Manager is not initialized.")); + OnLoaded(); + return; + } + + StreamableHandle = UAssetManager::GetStreamableManager().RequestAsyncLoad( + MoveTemp(PathsToLoad), + FStreamableDelegate::CreateUObject(this, &UDirectiveUtilTask_AsyncLoadAssets::OnLoaded), + FStreamableManager::DefaultAsyncLoadPriority); + + if (!StreamableHandle.IsValid()) + { + UE_LOG(LogDirectiveUtil, Warning, TEXT("Async Load Assets failed to start the load request.")); + OnLoaded(); + return; + } + + // Binding fails when the load already finished (all assets were in memory); complete directly, + // with the guard keeping the broadcast exactly once. + if (!StreamableHandle->BindUpdateDelegate(FStreamableUpdateDelegate::CreateUObject(this, &UDirectiveUtilTask_AsyncLoadAssets::OnUpdate))) + { + OnLoaded(); + } +} + +void UDirectiveUtilTask_AsyncLoadAssets::OnLoaded() +{ + if (bHasCompleted) + { + return; + } + bHasCompleted = true; + + TArray LoadedAssets; + LoadedAssets.Reserve(SoftAssets.Num()); + for (const TSoftObjectPtr& SoftAsset : SoftAssets) + { + LoadedAssets.Add(SoftAsset.Get()); + } + + Completed.Broadcast(LoadedAssets); + SetReadyToDestroy(); +} + +void UDirectiveUtilTask_AsyncLoadAssets::OnUpdate(TSharedRef Handle) +{ + if (bHasCompleted) + { + return; + } + + int32 LoadedCount = 0; + int32 RequestedCount = 0; + Handle->GetLoadedCount(LoadedCount, RequestedCount); + Progress.Broadcast(LoadedCount, RequestedCount); +} + +void UDirectiveUtilTask_AsyncLoadAssets::Cancel() +{ + bHasCompleted = true; + if (StreamableHandle.IsValid() && StreamableHandle->IsActive()) + { + StreamableHandle->CancelHandle(); + } + SetReadyToDestroy(); +} diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Tasks/DirectiveUtilTask_AsyncTrace.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Tasks/DirectiveUtilTask_AsyncTrace.cpp new file mode 100644 index 00000000..34d37198 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Tasks/DirectiveUtilTask_AsyncTrace.cpp @@ -0,0 +1,139 @@ +// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License. + + +#include "Tasks/DirectiveUtilTask_AsyncTrace.h" +#include "DirectiveUtilLogChannels.h" +#include "Engine/Engine.h" +#include "Engine/World.h" +#include "WorldCollision.h" +#include "CollisionShape.h" +#include "CollisionQueryParams.h" + +UDirectiveUtilTask_AsyncTrace* UDirectiveUtilTask_AsyncTrace::AsyncLineTraceByChannel(UObject* WorldContextObject, const FVector Start, const FVector End, const ETraceTypeQuery TraceChannel, const bool bMultiTrace) +{ + UDirectiveUtilTask_AsyncTrace* Action = NewObject(); + Action->WorldContextObject = WorldContextObject; + Action->Start = Start; + Action->End = End; + Action->TraceChannel = TraceChannel; + Action->bMultiTrace = bMultiTrace; + Action->Shape = EDirectiveUtilTraceShape::Line; + + if (WorldContextObject) + { + Action->RegisterWithGameInstance(WorldContextObject); + } + + return Action; +} + +UDirectiveUtilTask_AsyncTrace* UDirectiveUtilTask_AsyncTrace::AsyncSphereTraceByChannel(UObject* WorldContextObject, const FVector Start, const FVector End, const float Radius, const ETraceTypeQuery TraceChannel, const bool bMultiTrace) +{ + UDirectiveUtilTask_AsyncTrace* Action = NewObject(); + Action->WorldContextObject = WorldContextObject; + Action->Start = Start; + Action->End = End; + Action->Radius = Radius; + Action->TraceChannel = TraceChannel; + Action->bMultiTrace = bMultiTrace; + Action->Shape = EDirectiveUtilTraceShape::Sphere; + + if (WorldContextObject) + { + Action->RegisterWithGameInstance(WorldContextObject); + } + + return Action; +} + +UDirectiveUtilTask_AsyncTrace* UDirectiveUtilTask_AsyncTrace::AsyncBoxTraceByChannel(UObject* WorldContextObject, const FVector Start, const FVector End, const FVector HalfSize, const FRotator Orientation, const ETraceTypeQuery TraceChannel, const bool bMultiTrace) +{ + UDirectiveUtilTask_AsyncTrace* Action = NewObject(); + Action->WorldContextObject = WorldContextObject; + Action->Start = Start; + Action->End = End; + Action->HalfSize = HalfSize; + Action->Orientation = Orientation.Quaternion(); + Action->TraceChannel = TraceChannel; + Action->bMultiTrace = bMultiTrace; + Action->Shape = EDirectiveUtilTraceShape::Box; + + if (WorldContextObject) + { + Action->RegisterWithGameInstance(WorldContextObject); + } + + return Action; +} + +UDirectiveUtilTask_AsyncTrace* UDirectiveUtilTask_AsyncTrace::AsyncCapsuleTraceByChannel(UObject* WorldContextObject, const FVector Start, const FVector End, const float Radius, const float HalfHeight, const ETraceTypeQuery TraceChannel, const bool bMultiTrace) +{ + UDirectiveUtilTask_AsyncTrace* Action = NewObject(); + Action->WorldContextObject = WorldContextObject; + Action->Start = Start; + Action->End = End; + Action->Radius = Radius; + Action->HalfHeight = HalfHeight; + Action->TraceChannel = TraceChannel; + Action->bMultiTrace = bMultiTrace; + Action->Shape = EDirectiveUtilTraceShape::Capsule; + + if (WorldContextObject) + { + Action->RegisterWithGameInstance(WorldContextObject); + } + + return Action; +} + +void UDirectiveUtilTask_AsyncTrace::Activate() +{ + UWorld* World = WorldContextObject && GEngine + ? GEngine->GetWorldFromContextObject(WorldContextObject, EGetWorldErrorMode::LogAndReturnNull) + : nullptr; + if (!World) + { + UE_LOG(LogDirectiveUtil, Warning, TEXT("Async Trace failed to activate. World is null.")); + Completed.Broadcast(TArray()); + SetReadyToDestroy(); + return; + } + + const ECollisionChannel CollisionChannel = UEngineTypes::ConvertToCollisionChannel(TraceChannel); + const EAsyncTraceType AsyncType = bMultiTrace ? EAsyncTraceType::Multi : EAsyncTraceType::Single; + + FTraceDelegate TraceDelegate; + TraceDelegate.BindUObject(this, &UDirectiveUtilTask_AsyncTrace::OnTraceComplete); + + FCollisionQueryParams Params(FName(TEXT("DirectiveUtilAsyncTrace")), false); + + if (Shape == EDirectiveUtilTraceShape::Line) + { + World->AsyncLineTraceByChannel(AsyncType, Start, End, CollisionChannel, Params, FCollisionResponseParams::DefaultResponseParam, &TraceDelegate); + } + else + { + FCollisionShape CollisionShape; + switch (Shape) + { + case EDirectiveUtilTraceShape::Sphere: + CollisionShape = FCollisionShape::MakeSphere(Radius); + break; + case EDirectiveUtilTraceShape::Box: + CollisionShape = FCollisionShape::MakeBox(HalfSize); + break; + case EDirectiveUtilTraceShape::Capsule: + CollisionShape = FCollisionShape::MakeCapsule(Radius, HalfHeight); + break; + default: + break; + } + World->AsyncSweepByChannel(AsyncType, Start, End, Orientation, CollisionChannel, CollisionShape, Params, FCollisionResponseParams::DefaultResponseParam, &TraceDelegate); + } +} + +void UDirectiveUtilTask_AsyncTrace::OnTraceComplete(const FTraceHandle& Handle, FTraceDatum& Datum) +{ + Completed.Broadcast(Datum.OutHits); + SetReadyToDestroy(); +} diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Tasks/DirectiveUtilTask_Delay.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Tasks/DirectiveUtilTask_Delay.cpp new file mode 100644 index 00000000..bd083ed1 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Tasks/DirectiveUtilTask_Delay.cpp @@ -0,0 +1,57 @@ +// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License. + + +#include "Tasks/DirectiveUtilTask_Delay.h" +#include "DirectiveUtilLogChannels.h" +#include "Engine/Engine.h" +#include "Engine/World.h" +#include "TimerManager.h" + + +UDirectiveUtilTask_Delay* UDirectiveUtilTask_Delay::CancellableDelay(UObject* WorldContextObject, const float Duration) +{ + UDirectiveUtilTask_Delay* Action = NewObject(); + Action->WorldContextObject = WorldContextObject; + Action->Duration = Duration; + + if (WorldContextObject) + { + Action->RegisterWithGameInstance(WorldContextObject); + } + + return Action; +} + + +void UDirectiveUtilTask_Delay::EndTask() +{ + if (UWorld* World = GEngine ? GEngine->GetWorldFromContextObject(WorldContextObject, EGetWorldErrorMode::ReturnNull) : nullptr) + { + World->GetTimerManager().ClearTimer(TimerHandle); + } + SetReadyToDestroy(); +} + +void UDirectiveUtilTask_Delay::Activate() +{ + UWorld* World = WorldContextObject && GEngine + ? GEngine->GetWorldFromContextObject(WorldContextObject, EGetWorldErrorMode::LogAndReturnNull) + : nullptr; + if (!World) + { + UE_LOG(LogDirectiveUtil, Warning, TEXT("Cancellable Delay failed to activate. World is null.")); + SetReadyToDestroy(); + return; + } + + const float ClampedDuration = FMath::Max(Duration, KINDA_SMALL_NUMBER); + World->GetTimerManager().SetTimer(TimerHandle, this, &UDirectiveUtilTask_Delay::OnDelayComplete, ClampedDuration, false); + UE_LOG(LogDirectiveUtil, Verbose, TEXT("Cancellable Delay started for %f seconds."), ClampedDuration); +} + +void UDirectiveUtilTask_Delay::OnDelayComplete() +{ + UE_LOG(LogDirectiveUtil, Verbose, TEXT("Cancellable Delay completed.")); + Completed.Broadcast(); + SetReadyToDestroy(); +} diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Tasks/DirectiveUtilTask_MoveToLocation.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Tasks/DirectiveUtilTask_MoveToLocation.cpp new file mode 100644 index 00000000..0a1a056b --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Tasks/DirectiveUtilTask_MoveToLocation.cpp @@ -0,0 +1,280 @@ +// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License. + + +#include "Tasks/DirectiveUtilTask_MoveToLocation.h" +#include "DirectiveUtilLogChannels.h" +#include "Blueprint/AIBlueprintHelperLibrary.h" +#include "Engine/World.h" +#include "DrawDebugHelpers.h" +#include "Navigation/PathFollowingComponent.h" +#include "TimerManager.h" + + +UDirectiveUtilTask_MoveToLocation* UDirectiveUtilTask_MoveToLocation::MoveToLocation( + UObject* WorldContextObject, + AController* Controller, + const FVector Destination, + const float AcceptanceRadius, + const bool bCheckStuckMovement, + const float StuckThreshold, + const bool bDebugLineTrace) +{ + UDirectiveUtilTask_MoveToLocation* Action = NewObject(); + Action->Controller = Controller; + Action->Destination = Destination; + Action->AcceptanceRadius = AcceptanceRadius; + Action->bDebugLineTrace = bDebugLineTrace; + Action->StuckThreshold = StuckThreshold; + Action->bCheckStuckMovement = bCheckStuckMovement; + + if (WorldContextObject) + { + Action->RegisterWithGameInstance(WorldContextObject); + } + + return Action; +} + + +void UDirectiveUtilTask_MoveToLocation::EndTask() +{ + ExecuteCompleted(false); +} + +void UDirectiveUtilTask_MoveToLocation::Activate() +{ + if(!Controller || !Controller->GetPawn()) + { + ExecuteCompleted(false); + UE_LOG(LogDirectiveUtil, Warning, TEXT("Controller or pawn has been destroyed while moving to location. Aborting.")); + return; + } + + StartLocation = Controller->GetPawn()->GetActorLocation(); + LastCheckedLocation = StartLocation; + CurrentLocation = StartLocation; + + Controller->GetWorld()->GetTimerManager().SetTimer(TimerHandle, this, &UDirectiveUtilTask_MoveToLocation::CheckMoveToLocation, 0.1f, true); + + if (bCheckStuckMovement) + { + Controller->GetWorld()->GetTimerManager().SetTimer(StuckTimerHandle, this, &UDirectiveUtilTask_MoveToLocation::CheckStuckMovement, 3.f, true); + } + + UAIBlueprintHelperLibrary::SimpleMoveToLocation(Controller, Destination); + UE_LOG(LogDirectiveUtil, Verbose, TEXT("Moving controller to location (%s)."), *Destination.ToString()); + + if (bDebugLineTrace) + { + DrawDebugLine( + Controller->GetWorld(), + Destination + FVector(0, 0, 100), + Destination, + FColor::Green, + false, + 5.0f, + 0, + 1.0f + ); + } +} + +void UDirectiveUtilTask_MoveToLocation::CheckMoveToLocation() +{ + + if(!Controller || !Controller->GetPawn()) + { + ExecuteCompleted(false); + UE_LOG(LogDirectiveUtil, Warning, TEXT("Controller or pawn has been destroyed while moving to location. Aborting.")); + return; + } + + CurrentLocation = Controller->GetPawn()->GetActorLocation(); + UE_LOG(LogDirectiveUtil, Verbose, TEXT("Controller is moving to location (%s). Current distance: %f."), *Destination.ToString(), FVector::Dist(CurrentLocation, Destination)); + + if (FVector::Dist(CurrentLocation, Destination) < AcceptanceRadius) + { + UE_LOG(LogDirectiveUtil, Verbose, TEXT("Controller has moved to location.")); + ExecuteCompleted(true); + return; + } + + const UPathFollowingComponent* PathFollowing = Controller->FindComponentByClass(); + if (!PathFollowing || PathFollowing->GetStatus() == EPathFollowingStatus::Idle) + { + UE_LOG(LogDirectiveUtil, Verbose, TEXT("Path following has stopped. Completing move to location.")); + ExecuteCompleted(FVector::Dist(CurrentLocation, Destination) < AcceptanceRadius); + } +} + +void UDirectiveUtilTask_MoveToLocation::CheckStuckMovement() +{ + if(!Controller || !Controller->GetPawn()) + { + ExecuteCompleted(false); + UE_LOG(LogDirectiveUtil, Warning, TEXT("Controller or pawn has been destroyed while moving to location. Aborting.")); + return; + } + + if (FVector::Dist(CurrentLocation, LastCheckedLocation) < StuckThreshold) + { + UE_LOG(LogDirectiveUtil, Warning, TEXT("Controller is stuck while moving to location. Aborting")); + ExecuteCompleted(false); + } + + LastCheckedLocation = CurrentLocation; +} + +void UDirectiveUtilTask_MoveToLocation::ExecuteCompleted(const bool bSuccess) +{ + if (bHasCompleted) + { + return; + } + bHasCompleted = true; + + UE_LOG(LogDirectiveUtil, Log, TEXT("Movement to location completed. Success: %s."), bSuccess ? TEXT("true") : TEXT("false")); + + if (Controller) + { + Controller->GetWorld()->GetTimerManager().ClearTimer(TimerHandle); + Controller->GetWorld()->GetTimerManager().ClearTimer(StuckTimerHandle); + } + + Completed.Broadcast(bSuccess); + + Controller = nullptr; + Destination = FVector::ZeroVector; + + SetReadyToDestroy(); +} + +UDirectiveUtilTask_MoveToActor* UDirectiveUtilTask_MoveToActor::MoveToActor( + UObject* WorldContextObject, + AController* Controller, + AActor* Goal, + const float AcceptanceRadius, + const bool bCheckStuckMovement, + const float StuckThreshold) +{ + UDirectiveUtilTask_MoveToActor* Action = NewObject(); + Action->Controller = Controller; + Action->Goal = Goal; + Action->AcceptanceRadius = AcceptanceRadius; + Action->StuckThreshold = StuckThreshold; + Action->bCheckStuckMovement = bCheckStuckMovement; + + if (WorldContextObject) + { + Action->RegisterWithGameInstance(WorldContextObject); + } + + return Action; +} + +void UDirectiveUtilTask_MoveToActor::EndTask() +{ + ExecuteCompleted(false); +} + +void UDirectiveUtilTask_MoveToActor::Activate() +{ + if(!Controller || !Controller->GetPawn() || !IsValid(Goal)) + { + ExecuteCompleted(false); + UE_LOG(LogDirectiveUtil, Warning, TEXT("Controller, pawn, or goal has been destroyed while moving to actor. Aborting.")); + return; + } + + StartLocation = Controller->GetPawn()->GetActorLocation(); + LastCheckedLocation = StartLocation; + CurrentLocation = StartLocation; + + Controller->GetWorld()->GetTimerManager().SetTimer(TimerHandle, this, &UDirectiveUtilTask_MoveToActor::CheckMoveToActor, 0.1f, true); + + if (bCheckStuckMovement) + { + Controller->GetWorld()->GetTimerManager().SetTimer(StuckTimerHandle, this, &UDirectiveUtilTask_MoveToActor::CheckStuckMovement, 3.f, true); + } + + UAIBlueprintHelperLibrary::SimpleMoveToActor(Controller, Goal); + UE_LOG(LogDirectiveUtil, Verbose, TEXT("Moving controller to actor (%s)."), *GetNameSafe(Goal)); +} + +void UDirectiveUtilTask_MoveToActor::CheckMoveToActor() +{ + if(!Controller || !Controller->GetPawn()) + { + ExecuteCompleted(false); + UE_LOG(LogDirectiveUtil, Warning, TEXT("Controller or pawn has been destroyed while moving to actor. Aborting.")); + return; + } + + if (!IsValid(Goal)) + { + ExecuteCompleted(false); + UE_LOG(LogDirectiveUtil, Warning, TEXT("Goal actor has been destroyed while moving to actor. Aborting.")); + return; + } + + // The goal can move, so its location is re-read every poll. + const FVector GoalLocation = Goal->GetActorLocation(); + CurrentLocation = Controller->GetPawn()->GetActorLocation(); + UE_LOG(LogDirectiveUtil, Verbose, TEXT("Controller is moving to actor (%s). Current distance: %f."), *GetNameSafe(Goal), FVector::Dist(CurrentLocation, GoalLocation)); + + if (FVector::Dist(CurrentLocation, GoalLocation) < AcceptanceRadius) + { + UE_LOG(LogDirectiveUtil, Verbose, TEXT("Controller has moved to actor.")); + ExecuteCompleted(true); + return; + } + + const UPathFollowingComponent* PathFollowing = Controller->FindComponentByClass(); + if (!PathFollowing || PathFollowing->GetStatus() == EPathFollowingStatus::Idle) + { + UE_LOG(LogDirectiveUtil, Verbose, TEXT("Path following has stopped. Completing move to actor.")); + ExecuteCompleted(FVector::Dist(CurrentLocation, GoalLocation) < AcceptanceRadius); + } +} + +void UDirectiveUtilTask_MoveToActor::CheckStuckMovement() +{ + if(!Controller || !Controller->GetPawn()) + { + ExecuteCompleted(false); + UE_LOG(LogDirectiveUtil, Warning, TEXT("Controller or pawn has been destroyed while moving to actor. Aborting.")); + return; + } + + if (FVector::Dist(CurrentLocation, LastCheckedLocation) < StuckThreshold) + { + UE_LOG(LogDirectiveUtil, Warning, TEXT("Controller is stuck while moving to actor. Aborting")); + ExecuteCompleted(false); + } + + LastCheckedLocation = CurrentLocation; +} + +void UDirectiveUtilTask_MoveToActor::ExecuteCompleted(const bool bSuccess) +{ + if (bHasCompleted) + { + return; + } + bHasCompleted = true; + + UE_LOG(LogDirectiveUtil, Log, TEXT("Movement to actor completed. Success: %s."), bSuccess ? TEXT("true") : TEXT("false")); + + if (Controller) + { + Controller->GetWorld()->GetTimerManager().ClearTimer(TimerHandle); + Controller->GetWorld()->GetTimerManager().ClearTimer(StuckTimerHandle); + } + + Completed.Broadcast(bSuccess); + + Controller = nullptr; + Goal = nullptr; + + SetReadyToDestroy(); +} diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/DirectiveUtilLogChannels.h b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/DirectiveUtilLogChannels.h new file mode 100644 index 00000000..b9240e3b --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/DirectiveUtilLogChannels.h @@ -0,0 +1,8 @@ +// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License. + +#pragma once + +#include "Logging/LogMacros.h" + +DIRECTIVEUTILITIESRUNTIME_API DECLARE_LOG_CATEGORY_EXTERN(LogDirectiveUtil, Log, All); +DIRECTIVEUTILITIESRUNTIME_API DECLARE_LOG_CATEGORY_EXTERN(LogDirectiveUtilEditor, Log, All); \ No newline at end of file diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/DirectiveUtilitiesRuntime.h b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/DirectiveUtilitiesRuntime.h new file mode 100644 index 00000000..f11d5234 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/DirectiveUtilitiesRuntime.h @@ -0,0 +1,15 @@ +// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License. + +#pragma once + +#include "CoreMinimal.h" +#include "Modules/ModuleManager.h" + +class FDirectiveUtilitiesRuntimeModule : public IModuleInterface +{ +public: + + /** IModuleInterface implementation */ + virtual void StartupModule() override; + virtual void ShutdownModule() override; +}; diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilArrayFunctionLibrary.h b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilArrayFunctionLibrary.h new file mode 100644 index 00000000..a959f74d --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilArrayFunctionLibrary.h @@ -0,0 +1,716 @@ +// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License. + +#pragma once + +#include "CoreMinimal.h" +#include "Kismet/BlueprintFunctionLibrary.h" +#include "Net/Core/PushModel/PushModel.h" +#include "DirectiveUtilArrayFunctionLibrary.generated.h" + +/** + * UDirectiveUtilArrayFunctionLibrary + * A collection of array utility functions that improve the usability of arrays in Blueprints. + */ +UCLASS() +class DIRECTIVEUTILITIESRUNTIME_API UDirectiveUtilArrayFunctionLibrary : public UBlueprintFunctionLibrary +{ + GENERATED_BODY() + +public: + + /** + * Returns the next index in the array. + * If the next index is greater than the last array index and bLoop is enabled, the index will loop back to the start of the array. + * Otherwise, the last index will be returned. + * Returns INDEX_NONE for an empty array. + * @param TargetArray - The array to get the next index for. + * @param Index - The current index. + * @param bLoop - If true, the index will loop back to the beginning of the array when the next index is greater than the last array index. + * Otherwise, the last index will be returned. + * @returns The next index in the array. + */ + UFUNCTION(BlueprintPure, CustomThunk, meta=(DisplayName = "Next Index", CompactNodeTitle = "NEXT INDEX", ArrayParm = "TargetArray", BlueprintThreadSafe), Category="Directive Utilities|Array") + static int32 Array_NextIndex(const TArray& TargetArray, const int32 Index, const bool bLoop); + + /** + * Returns the previous index in the array. + * If the previous index is less than 0 and bLoop is enabled, the index will loop back to the end of the array. + * Otherwise, 0 will be returned. + * Returns INDEX_NONE for an empty array. + * @param TargetArray - The array to get the previous index for. + * @param Index - The current index. + * @param bLoop - If the next index is greater than the last array index and bLoop is enabled, the index will loop back to the start of the array. + * Otherwise, the last index will be returned. + * @returns The previous index in the array. + */ + UFUNCTION(BlueprintPure, CustomThunk, meta=(DisplayName = "Previous Index", CompactNodeTitle = "PREV INDEX", ArrayParm = "TargetArray", BlueprintThreadSafe), Category="Directive Utilities|Array") + static int32 Array_PreviousIndex(const TArray& TargetArray, const int32 Index, const bool bLoop); + + /** + * Removes duplicate elements from the array in-place. + * @param TargetArray - The array to remove duplicates from. + */ + UFUNCTION(BlueprintCallable, CustomThunk, meta=(DisplayName = "Remove Duplicates", CompactNodeTitle = "REMOVE DUPLICATES", ArrayParm = "TargetArray"), Category="Directive Utilities|Array") + static void Array_RemoveDuplicates(const TArray& TargetArray); + + /** + * Returns a copy of the first element of the array. + * @param TargetArray - The array to read from. + * @param OutItem - [out] A copy of the first element, or the default value if the array is empty. + * @returns True if the array contained a valid first element. + */ + UFUNCTION(BlueprintPure, CustomThunk, meta=(DisplayName = "Get Valid First Array Item (Copy)", ArrayParm = "TargetArray", ArrayTypeDependentParams = "OutItem", BlueprintThreadSafe), Category="Directive Utilities|Array") + static bool Array_GetValidFirstItemCopy(const TArray& TargetArray, int32& OutItem); + + /** + * Returns a copy of the last element of the array. + * @param TargetArray - The array to read from. + * @param OutItem - [out] A copy of the last element, or the default value if the array is empty. + * @returns True if the array contained a valid last element. + */ + UFUNCTION(BlueprintPure, CustomThunk, meta=(DisplayName = "Get Valid Last Array Item (Copy)", ArrayParm = "TargetArray", ArrayTypeDependentParams = "OutItem", BlueprintThreadSafe), Category="Directive Utilities|Array") + static bool Array_GetValidLastItemCopy(const TArray& TargetArray, int32& OutItem); + + /** + * Returns a copy of the element at the given index, if the index is valid. + * @param TargetArray - The array to read from. + * @param Index - The index to read. + * @param OutItem - [out] A copy of the element, or the default value if the index is invalid. + * @returns True if the index was valid. + */ + UFUNCTION(BlueprintPure, CustomThunk, meta=(DisplayName = "Get Valid Array Item From Index (Copy)", ArrayParm = "TargetArray", ArrayTypeDependentParams = "OutItem", BlueprintThreadSafe), Category="Directive Utilities|Array") + static bool Array_GetValidItemFromIndexCopy(const TArray& TargetArray, const int32 Index, int32& OutItem); + + /** + * Returns a copy of a random element from the array. + * @param TargetArray - The array to read from. + * @param OutItem - [out] A copy of the randomly selected element, or the default value if the array is empty. + * @param OutIndex - [out] The index of the selected element, or INDEX_NONE if the array is empty. + * @returns True if a valid element was selected. + */ + UFUNCTION(BlueprintCallable, CustomThunk, meta=(DisplayName = "Get Random Valid Array Item", CompactNodeTitle = "RANDOM", ArrayParm = "TargetArray", ArrayTypeDependentParams = "OutItem"), Category="Directive Utilities|Array") + static bool Array_GetRandomItem(const TArray& TargetArray, int32& OutItem, int32& OutIndex); + + /** + * Returns a copy of the last element of the array without removing it. + * @param TargetArray - The array to read from. + * @param OutItem - [out] A copy of the last element, or the default value if the array is empty. + */ + UFUNCTION(BlueprintPure, CustomThunk, meta=(DisplayName = "Last Value", CompactNodeTitle = "LAST", ArrayParm = "TargetArray", ArrayTypeDependentParams = "OutItem", BlueprintThreadSafe), Category="Directive Utilities|Array") + static void Array_LastValue(const TArray& TargetArray, int32& OutItem); + + /** + * Removes the last element of the array and returns a copy of it. + * @param TargetArray - The array to pop from. + * @param OutItem - [out] A copy of the removed element, or the default value if the array is empty. + * @returns True if an element was removed. + */ + UFUNCTION(BlueprintCallable, CustomThunk, meta=(DisplayName = "Pop", ArrayParm = "TargetArray", ArrayTypeDependentParams = "OutItem"), Category="Directive Utilities|Array") + static bool Array_Pop(const TArray& TargetArray, int32& OutItem); + + /** + * Removes the first element of the array and returns a copy of it. + * @param TargetArray - The array to pop from. + * @param OutItem - [out] A copy of the removed element, or the default value if the array is empty. + * @returns True if an element was removed. + */ + UFUNCTION(BlueprintCallable, CustomThunk, meta=(DisplayName = "Pop First", ArrayParm = "TargetArray", ArrayTypeDependentParams = "OutItem"), Category="Directive Utilities|Array") + static bool Array_PopFirst(const TArray& TargetArray, int32& OutItem); + + /** + * Removes the element at the given index by swapping it with the last element (does not preserve order). + * This is O(1) but changes the position of the previously-last element. + * @param TargetArray - The array to remove from. + * @param Index - The index to remove. + * @returns True if the index was valid and an element was removed. + */ + UFUNCTION(BlueprintCallable, CustomThunk, meta=(DisplayName = "Remove At Swap", ArrayParm = "TargetArray"), Category="Directive Utilities|Array") + static bool Array_RemoveAtSwap(const TArray& TargetArray, const int32 Index); + + /** + * Returns a copy of a contiguous range of the array. The range is clamped to the array bounds. + * @param TargetArray - The array to slice. + * @param StartIndex - The index to start copying from (clamped to [0, Length]). + * @param Count - The number of elements to copy. Values <= 0 produce an empty array. + * @param OutArray - [out] The sliced copy. + */ + UFUNCTION(BlueprintPure, CustomThunk, meta=(DisplayName = "Slice", ArrayParm = "TargetArray,OutArray", ArrayTypeDependentParams = "OutArray", BlueprintThreadSafe), Category="Directive Utilities|Array") + static void Array_Slice(const TArray& TargetArray, const int32 StartIndex, const int32 Count, TArray& OutArray); + + /** + * Cyclically rotates the elements of the array in place. + * @param TargetArray - The array to rotate. + * @param Shift - The number of positions to rotate. Positive rotates toward the end; negative toward the start. + */ + UFUNCTION(BlueprintCallable, CustomThunk, meta=(DisplayName = "Rotate", ArrayParm = "TargetArray"), Category="Directive Utilities|Array") + static void Array_Rotate(const TArray& TargetArray, const int32 Shift); + + /** + * Returns a copy of the array with duplicates removed, keeping the first occurrence and preserving order. + * Unlike Remove Duplicates, this does not modify the input array. + * @param TargetArray - The array to read from. + * @param OutArray - [out] The de-duplicated copy. + */ + UFUNCTION(BlueprintPure, CustomThunk, meta=(DisplayName = "Get Distinct (Copy)", ArrayParm = "TargetArray,OutArray", ArrayTypeDependentParams = "OutArray", BlueprintThreadSafe), Category="Directive Utilities|Array") + static void Array_GetDistinct(const TArray& TargetArray, TArray& OutArray); + + /** + * Counts how many times an item appears in the array. + * @param TargetArray - The array to search. + * @param ItemToCount - The item to count. + * @returns The number of occurrences. + */ + UFUNCTION(BlueprintPure, CustomThunk, meta=(DisplayName = "Count Occurrences", ArrayParm = "TargetArray", ArrayTypeDependentParams = "ItemToCount", AutoCreateRefTerm = "ItemToCount", BlueprintThreadSafe), Category="Directive Utilities|Array") + static int32 Array_CountOccurrences(const TArray& TargetArray, const int32& ItemToCount); + + /** + * Returns the most frequently occurring element of the array (ties resolve to the earliest such element). + * @param TargetArray - The array to read from. + * @param OutItem - [out] A copy of the most common element, or the default value if the array is empty. + * @param OutCount - [out] The number of times the most common element occurs. + * @returns True if the array was non-empty. + */ + UFUNCTION(BlueprintPure, CustomThunk, meta=(DisplayName = "Get Most Common", ArrayParm = "TargetArray", ArrayTypeDependentParams = "OutItem", BlueprintThreadSafe), Category="Directive Utilities|Array") + static bool Array_GetMostCommon(const TArray& TargetArray, int32& OutItem, int32& OutCount); + + + /*~ + * Native functions that will be called by the below custom thunk layers, which read off the property address and call the appropriate native handler. + * Based off UKismetArrayLibrary implementation + ~*/ + + static int32 GenericArray_NextIndex(const void* TargetArray, const FArrayProperty* ArrayProperty, int32 Index, bool bLoop); + static int32 GenericArray_PreviousIndex(const void* TargetArray, const FArrayProperty* ArrayProperty, int32 Index, bool bLoop); + static void GenericArray_RemoveDuplicates(void* TargetArray, const FArrayProperty* ArrayProperty); + static bool GenericArray_GetItemAtIndex(const void* TargetArray, const FArrayProperty* ArrayProperty, int32 Index, void* OutItemPtr); + static bool GenericArray_GetFirstItem(const void* TargetArray, const FArrayProperty* ArrayProperty, void* OutItemPtr); + static bool GenericArray_GetLastItem(const void* TargetArray, const FArrayProperty* ArrayProperty, void* OutItemPtr); + static bool GenericArray_GetRandomItem(const void* TargetArray, const FArrayProperty* ArrayProperty, void* OutItemPtr, int32* OutIndex); + static bool GenericArray_Pop(void* TargetArray, const FArrayProperty* ArrayProperty, void* OutItemPtr); + static bool GenericArray_PopFirst(void* TargetArray, const FArrayProperty* ArrayProperty, void* OutItemPtr); + static bool GenericArray_RemoveAtSwap(void* TargetArray, const FArrayProperty* ArrayProperty, int32 Index); + static void GenericArray_Slice(const void* TargetArray, const FArrayProperty* TargetArrayProperty, int32 StartIndex, int32 Count, void* OutArray, const FArrayProperty* OutArrayProperty); + static void GenericArray_Rotate(void* TargetArray, const FArrayProperty* ArrayProperty, int32 Shift); + static void GenericArray_GetDistinct(const void* TargetArray, const FArrayProperty* TargetArrayProperty, void* OutArray, const FArrayProperty* OutArrayProperty); + static int32 GenericArray_CountOccurrences(const void* TargetArray, const FArrayProperty* ArrayProperty, const void* ItemToCount); + static bool GenericArray_GetMostCommon(const void* TargetArray, const FArrayProperty* ArrayProperty, void* OutItemPtr, int32* OutCount); + + /*~ + * Custom thunk layers that read off the property address and call the appropriate native handler. + * Based off UKismetArrayLibrary implementation + ~*/ + + DECLARE_FUNCTION(execArray_NextIndex) + { + Stack.MostRecentProperty = nullptr; + Stack.StepCompiledIn(nullptr); + const void* ArrayAddr = Stack.MostRecentPropertyAddress; + const FArrayProperty* ArrayProperty = CastField(Stack.MostRecentProperty); + if (!ArrayProperty) + { + Stack.bArrayContextFailed = true; + return; + } + P_GET_PROPERTY(FIntProperty, Index); + P_GET_UBOOL(bLoop); + P_FINISH; + P_NATIVE_BEGIN; + + *static_cast(RESULT_PARAM) = GenericArray_NextIndex(ArrayAddr, ArrayProperty, Index, bLoop); + P_NATIVE_END; + } + + DECLARE_FUNCTION(execArray_PreviousIndex) + { + Stack.MostRecentProperty = nullptr; + Stack.StepCompiledIn(nullptr); + const void* ArrayAddr = Stack.MostRecentPropertyAddress; + const FArrayProperty* ArrayProperty = CastField(Stack.MostRecentProperty); + if (!ArrayProperty) + { + Stack.bArrayContextFailed = true; + return; + } + P_GET_PROPERTY(FIntProperty, Index); + P_GET_UBOOL(bLoop); + P_FINISH; + P_NATIVE_BEGIN; + + *static_cast(RESULT_PARAM) = GenericArray_PreviousIndex(ArrayAddr, ArrayProperty, Index, bLoop); + P_NATIVE_END; + } + + DECLARE_FUNCTION(execArray_RemoveDuplicates) + { + Stack.MostRecentProperty = nullptr; + Stack.StepCompiledIn(nullptr); + void* ArrayAddr = Stack.MostRecentPropertyAddress; + FArrayProperty* ArrayProperty = CastField(Stack.MostRecentProperty); + if (!ArrayProperty) + { + Stack.bArrayContextFailed = true; + return; + } + P_FINISH; + P_NATIVE_BEGIN; + MARK_PROPERTY_DIRTY(Stack.Object, ArrayProperty); + GenericArray_RemoveDuplicates(ArrayAddr, ArrayProperty); + P_NATIVE_END; + } + + DECLARE_FUNCTION(execArray_GetValidFirstItemCopy) + { + Stack.MostRecentProperty = nullptr; + Stack.StepCompiledIn(nullptr); + const void* ArrayAddr = Stack.MostRecentPropertyAddress; + const FArrayProperty* ArrayProperty = CastField(Stack.MostRecentProperty); + if (!ArrayProperty) + { + Stack.bArrayContextFailed = true; + return; + } + + const FProperty* InnerProp = ArrayProperty->Inner; + const int32 PropertySize = InnerProp->GetElementSize() * InnerProp->ArrayDim; + void* StorageSpace = FMemory_Alloca(PropertySize); + InnerProp->InitializeValue(StorageSpace); + + Stack.MostRecentPropertyAddress = nullptr; + Stack.MostRecentPropertyContainer = nullptr; + Stack.StepCompiledIn(StorageSpace); + void* ItemPtr; + if (Stack.MostRecentPropertyAddress != nullptr && Stack.MostRecentProperty != nullptr + && PropertySize == Stack.MostRecentProperty->GetElementSize() * Stack.MostRecentProperty->ArrayDim + && (Stack.MostRecentProperty->GetClass()->IsChildOf(InnerProp->GetClass()) + || InnerProp->GetClass()->IsChildOf(Stack.MostRecentProperty->GetClass()))) + { + ItemPtr = Stack.MostRecentPropertyAddress; + } + else + { + ItemPtr = StorageSpace; + } + + P_FINISH; + P_NATIVE_BEGIN; + *static_cast(RESULT_PARAM) = GenericArray_GetFirstItem(ArrayAddr, ArrayProperty, ItemPtr); + P_NATIVE_END; + InnerProp->DestroyValue(StorageSpace); + } + + DECLARE_FUNCTION(execArray_GetValidLastItemCopy) + { + Stack.MostRecentProperty = nullptr; + Stack.StepCompiledIn(nullptr); + const void* ArrayAddr = Stack.MostRecentPropertyAddress; + const FArrayProperty* ArrayProperty = CastField(Stack.MostRecentProperty); + if (!ArrayProperty) + { + Stack.bArrayContextFailed = true; + return; + } + + const FProperty* InnerProp = ArrayProperty->Inner; + const int32 PropertySize = InnerProp->GetElementSize() * InnerProp->ArrayDim; + void* StorageSpace = FMemory_Alloca(PropertySize); + InnerProp->InitializeValue(StorageSpace); + + Stack.MostRecentPropertyAddress = nullptr; + Stack.MostRecentPropertyContainer = nullptr; + Stack.StepCompiledIn(StorageSpace); + void* ItemPtr; + if (Stack.MostRecentPropertyAddress != nullptr && Stack.MostRecentProperty != nullptr + && PropertySize == Stack.MostRecentProperty->GetElementSize() * Stack.MostRecentProperty->ArrayDim + && (Stack.MostRecentProperty->GetClass()->IsChildOf(InnerProp->GetClass()) + || InnerProp->GetClass()->IsChildOf(Stack.MostRecentProperty->GetClass()))) + { + ItemPtr = Stack.MostRecentPropertyAddress; + } + else + { + ItemPtr = StorageSpace; + } + + P_FINISH; + P_NATIVE_BEGIN; + *static_cast(RESULT_PARAM) = GenericArray_GetLastItem(ArrayAddr, ArrayProperty, ItemPtr); + P_NATIVE_END; + InnerProp->DestroyValue(StorageSpace); + } + + DECLARE_FUNCTION(execArray_GetValidItemFromIndexCopy) + { + Stack.MostRecentProperty = nullptr; + Stack.StepCompiledIn(nullptr); + const void* ArrayAddr = Stack.MostRecentPropertyAddress; + const FArrayProperty* ArrayProperty = CastField(Stack.MostRecentProperty); + if (!ArrayProperty) + { + Stack.bArrayContextFailed = true; + return; + } + P_GET_PROPERTY(FIntProperty, Index); + + const FProperty* InnerProp = ArrayProperty->Inner; + const int32 PropertySize = InnerProp->GetElementSize() * InnerProp->ArrayDim; + void* StorageSpace = FMemory_Alloca(PropertySize); + InnerProp->InitializeValue(StorageSpace); + + Stack.MostRecentPropertyAddress = nullptr; + Stack.MostRecentPropertyContainer = nullptr; + Stack.StepCompiledIn(StorageSpace); + void* ItemPtr; + if (Stack.MostRecentPropertyAddress != nullptr && Stack.MostRecentProperty != nullptr + && PropertySize == Stack.MostRecentProperty->GetElementSize() * Stack.MostRecentProperty->ArrayDim + && (Stack.MostRecentProperty->GetClass()->IsChildOf(InnerProp->GetClass()) + || InnerProp->GetClass()->IsChildOf(Stack.MostRecentProperty->GetClass()))) + { + ItemPtr = Stack.MostRecentPropertyAddress; + } + else + { + ItemPtr = StorageSpace; + } + + P_FINISH; + P_NATIVE_BEGIN; + *static_cast(RESULT_PARAM) = GenericArray_GetItemAtIndex(ArrayAddr, ArrayProperty, Index, ItemPtr); + P_NATIVE_END; + InnerProp->DestroyValue(StorageSpace); + } + + DECLARE_FUNCTION(execArray_GetRandomItem) + { + Stack.MostRecentProperty = nullptr; + Stack.StepCompiledIn(nullptr); + const void* ArrayAddr = Stack.MostRecentPropertyAddress; + const FArrayProperty* ArrayProperty = CastField(Stack.MostRecentProperty); + if (!ArrayProperty) + { + Stack.bArrayContextFailed = true; + return; + } + + const FProperty* InnerProp = ArrayProperty->Inner; + const int32 PropertySize = InnerProp->GetElementSize() * InnerProp->ArrayDim; + void* StorageSpace = FMemory_Alloca(PropertySize); + InnerProp->InitializeValue(StorageSpace); + + Stack.MostRecentPropertyAddress = nullptr; + Stack.MostRecentPropertyContainer = nullptr; + Stack.StepCompiledIn(StorageSpace); + void* ItemPtr; + if (Stack.MostRecentPropertyAddress != nullptr && Stack.MostRecentProperty != nullptr + && PropertySize == Stack.MostRecentProperty->GetElementSize() * Stack.MostRecentProperty->ArrayDim + && (Stack.MostRecentProperty->GetClass()->IsChildOf(InnerProp->GetClass()) + || InnerProp->GetClass()->IsChildOf(Stack.MostRecentProperty->GetClass()))) + { + ItemPtr = Stack.MostRecentPropertyAddress; + } + else + { + ItemPtr = StorageSpace; + } + + Stack.MostRecentProperty = nullptr; + Stack.MostRecentPropertyAddress = nullptr; + Stack.StepCompiledIn(nullptr); + int32* OutIndex = reinterpret_cast(Stack.MostRecentPropertyAddress); + + P_FINISH; + P_NATIVE_BEGIN; + *static_cast(RESULT_PARAM) = GenericArray_GetRandomItem(ArrayAddr, ArrayProperty, ItemPtr, OutIndex); + P_NATIVE_END; + InnerProp->DestroyValue(StorageSpace); + } + + DECLARE_FUNCTION(execArray_LastValue) + { + Stack.MostRecentProperty = nullptr; + Stack.StepCompiledIn(nullptr); + const void* ArrayAddr = Stack.MostRecentPropertyAddress; + const FArrayProperty* ArrayProperty = CastField(Stack.MostRecentProperty); + if (!ArrayProperty) + { + Stack.bArrayContextFailed = true; + return; + } + + const FProperty* InnerProp = ArrayProperty->Inner; + const int32 PropertySize = InnerProp->GetElementSize() * InnerProp->ArrayDim; + void* StorageSpace = FMemory_Alloca(PropertySize); + InnerProp->InitializeValue(StorageSpace); + + Stack.MostRecentPropertyAddress = nullptr; + Stack.MostRecentPropertyContainer = nullptr; + Stack.StepCompiledIn(StorageSpace); + void* ItemPtr; + if (Stack.MostRecentPropertyAddress != nullptr && Stack.MostRecentProperty != nullptr + && PropertySize == Stack.MostRecentProperty->GetElementSize() * Stack.MostRecentProperty->ArrayDim + && (Stack.MostRecentProperty->GetClass()->IsChildOf(InnerProp->GetClass()) + || InnerProp->GetClass()->IsChildOf(Stack.MostRecentProperty->GetClass()))) + { + ItemPtr = Stack.MostRecentPropertyAddress; + } + else + { + ItemPtr = StorageSpace; + } + + P_FINISH; + P_NATIVE_BEGIN; + GenericArray_GetLastItem(ArrayAddr, ArrayProperty, ItemPtr); + P_NATIVE_END; + InnerProp->DestroyValue(StorageSpace); + } + + DECLARE_FUNCTION(execArray_Pop) + { + Stack.MostRecentProperty = nullptr; + Stack.StepCompiledIn(nullptr); + void* ArrayAddr = Stack.MostRecentPropertyAddress; + FArrayProperty* ArrayProperty = CastField(Stack.MostRecentProperty); + if (!ArrayProperty) + { + Stack.bArrayContextFailed = true; + return; + } + + const FProperty* InnerProp = ArrayProperty->Inner; + const int32 PropertySize = InnerProp->GetElementSize() * InnerProp->ArrayDim; + void* StorageSpace = FMemory_Alloca(PropertySize); + InnerProp->InitializeValue(StorageSpace); + + Stack.MostRecentPropertyAddress = nullptr; + Stack.MostRecentPropertyContainer = nullptr; + Stack.StepCompiledIn(StorageSpace); + void* ItemPtr; + if (Stack.MostRecentPropertyAddress != nullptr && Stack.MostRecentProperty != nullptr + && PropertySize == Stack.MostRecentProperty->GetElementSize() * Stack.MostRecentProperty->ArrayDim + && (Stack.MostRecentProperty->GetClass()->IsChildOf(InnerProp->GetClass()) + || InnerProp->GetClass()->IsChildOf(Stack.MostRecentProperty->GetClass()))) + { + ItemPtr = Stack.MostRecentPropertyAddress; + } + else + { + ItemPtr = StorageSpace; + } + + P_FINISH; + P_NATIVE_BEGIN; + MARK_PROPERTY_DIRTY(Stack.Object, ArrayProperty); + *static_cast(RESULT_PARAM) = GenericArray_Pop(ArrayAddr, ArrayProperty, ItemPtr); + P_NATIVE_END; + InnerProp->DestroyValue(StorageSpace); + } + + DECLARE_FUNCTION(execArray_PopFirst) + { + Stack.MostRecentProperty = nullptr; + Stack.StepCompiledIn(nullptr); + void* ArrayAddr = Stack.MostRecentPropertyAddress; + FArrayProperty* ArrayProperty = CastField(Stack.MostRecentProperty); + if (!ArrayProperty) + { + Stack.bArrayContextFailed = true; + return; + } + + const FProperty* InnerProp = ArrayProperty->Inner; + const int32 PropertySize = InnerProp->GetElementSize() * InnerProp->ArrayDim; + void* StorageSpace = FMemory_Alloca(PropertySize); + InnerProp->InitializeValue(StorageSpace); + + Stack.MostRecentPropertyAddress = nullptr; + Stack.MostRecentPropertyContainer = nullptr; + Stack.StepCompiledIn(StorageSpace); + void* ItemPtr; + if (Stack.MostRecentPropertyAddress != nullptr && Stack.MostRecentProperty != nullptr + && PropertySize == Stack.MostRecentProperty->GetElementSize() * Stack.MostRecentProperty->ArrayDim + && (Stack.MostRecentProperty->GetClass()->IsChildOf(InnerProp->GetClass()) + || InnerProp->GetClass()->IsChildOf(Stack.MostRecentProperty->GetClass()))) + { + ItemPtr = Stack.MostRecentPropertyAddress; + } + else + { + ItemPtr = StorageSpace; + } + + P_FINISH; + P_NATIVE_BEGIN; + MARK_PROPERTY_DIRTY(Stack.Object, ArrayProperty); + *static_cast(RESULT_PARAM) = GenericArray_PopFirst(ArrayAddr, ArrayProperty, ItemPtr); + P_NATIVE_END; + InnerProp->DestroyValue(StorageSpace); + } + + DECLARE_FUNCTION(execArray_RemoveAtSwap) + { + Stack.MostRecentProperty = nullptr; + Stack.StepCompiledIn(nullptr); + void* ArrayAddr = Stack.MostRecentPropertyAddress; + FArrayProperty* ArrayProperty = CastField(Stack.MostRecentProperty); + if (!ArrayProperty) + { + Stack.bArrayContextFailed = true; + return; + } + P_GET_PROPERTY(FIntProperty, Index); + P_FINISH; + P_NATIVE_BEGIN; + MARK_PROPERTY_DIRTY(Stack.Object, ArrayProperty); + *static_cast(RESULT_PARAM) = GenericArray_RemoveAtSwap(ArrayAddr, ArrayProperty, Index); + P_NATIVE_END; + } + + DECLARE_FUNCTION(execArray_Slice) + { + Stack.MostRecentProperty = nullptr; + Stack.StepCompiledIn(nullptr); + const void* SourceArrayAddr = Stack.MostRecentPropertyAddress; + const FArrayProperty* SourceArrayProperty = CastField(Stack.MostRecentProperty); + if (!SourceArrayProperty) + { + Stack.bArrayContextFailed = true; + return; + } + P_GET_PROPERTY(FIntProperty, StartIndex); + P_GET_PROPERTY(FIntProperty, Count); + + Stack.MostRecentProperty = nullptr; + Stack.StepCompiledIn(nullptr); + void* OutArrayAddr = Stack.MostRecentPropertyAddress; + const FArrayProperty* OutArrayProperty = CastField(Stack.MostRecentProperty); + if (!OutArrayProperty) + { + Stack.bArrayContextFailed = true; + return; + } + + P_FINISH; + P_NATIVE_BEGIN; + GenericArray_Slice(SourceArrayAddr, SourceArrayProperty, StartIndex, Count, OutArrayAddr, OutArrayProperty); + P_NATIVE_END; + } + + DECLARE_FUNCTION(execArray_Rotate) + { + Stack.MostRecentProperty = nullptr; + Stack.StepCompiledIn(nullptr); + void* ArrayAddr = Stack.MostRecentPropertyAddress; + FArrayProperty* ArrayProperty = CastField(Stack.MostRecentProperty); + if (!ArrayProperty) + { + Stack.bArrayContextFailed = true; + return; + } + P_GET_PROPERTY(FIntProperty, Shift); + P_FINISH; + P_NATIVE_BEGIN; + MARK_PROPERTY_DIRTY(Stack.Object, ArrayProperty); + GenericArray_Rotate(ArrayAddr, ArrayProperty, Shift); + P_NATIVE_END; + } + + DECLARE_FUNCTION(execArray_GetDistinct) + { + Stack.MostRecentProperty = nullptr; + Stack.StepCompiledIn(nullptr); + const void* SourceArrayAddr = Stack.MostRecentPropertyAddress; + const FArrayProperty* SourceArrayProperty = CastField(Stack.MostRecentProperty); + if (!SourceArrayProperty) + { + Stack.bArrayContextFailed = true; + return; + } + + Stack.MostRecentProperty = nullptr; + Stack.StepCompiledIn(nullptr); + void* OutArrayAddr = Stack.MostRecentPropertyAddress; + const FArrayProperty* OutArrayProperty = CastField(Stack.MostRecentProperty); + if (!OutArrayProperty) + { + Stack.bArrayContextFailed = true; + return; + } + + P_FINISH; + P_NATIVE_BEGIN; + GenericArray_GetDistinct(SourceArrayAddr, SourceArrayProperty, OutArrayAddr, OutArrayProperty); + P_NATIVE_END; + } + + DECLARE_FUNCTION(execArray_CountOccurrences) + { + Stack.MostRecentProperty = nullptr; + Stack.StepCompiledIn(nullptr); + const void* ArrayAddr = Stack.MostRecentPropertyAddress; + const FArrayProperty* ArrayProperty = CastField(Stack.MostRecentProperty); + if (!ArrayProperty) + { + Stack.bArrayContextFailed = true; + return; + } + + const FProperty* InnerProp = ArrayProperty->Inner; + const int32 PropertySize = InnerProp->GetElementSize() * InnerProp->ArrayDim; + void* StorageSpace = FMemory_Alloca(PropertySize); + InnerProp->InitializeValue(StorageSpace); + Stack.MostRecentPropertyAddress = nullptr; + Stack.MostRecentPropertyContainer = nullptr; + Stack.StepCompiledIn(StorageSpace); + + P_FINISH; + P_NATIVE_BEGIN; + *static_cast(RESULT_PARAM) = GenericArray_CountOccurrences(ArrayAddr, ArrayProperty, StorageSpace); + P_NATIVE_END; + InnerProp->DestroyValue(StorageSpace); + } + + DECLARE_FUNCTION(execArray_GetMostCommon) + { + Stack.MostRecentProperty = nullptr; + Stack.StepCompiledIn(nullptr); + const void* ArrayAddr = Stack.MostRecentPropertyAddress; + const FArrayProperty* ArrayProperty = CastField(Stack.MostRecentProperty); + if (!ArrayProperty) + { + Stack.bArrayContextFailed = true; + return; + } + + const FProperty* InnerProp = ArrayProperty->Inner; + const int32 PropertySize = InnerProp->GetElementSize() * InnerProp->ArrayDim; + void* StorageSpace = FMemory_Alloca(PropertySize); + InnerProp->InitializeValue(StorageSpace); + + Stack.MostRecentPropertyAddress = nullptr; + Stack.MostRecentPropertyContainer = nullptr; + Stack.StepCompiledIn(StorageSpace); + void* ItemPtr; + if (Stack.MostRecentPropertyAddress != nullptr && Stack.MostRecentProperty != nullptr + && PropertySize == Stack.MostRecentProperty->GetElementSize() * Stack.MostRecentProperty->ArrayDim + && (Stack.MostRecentProperty->GetClass()->IsChildOf(InnerProp->GetClass()) + || InnerProp->GetClass()->IsChildOf(Stack.MostRecentProperty->GetClass()))) + { + ItemPtr = Stack.MostRecentPropertyAddress; + } + else + { + ItemPtr = StorageSpace; + } + + Stack.MostRecentProperty = nullptr; + Stack.MostRecentPropertyAddress = nullptr; + Stack.StepCompiledIn(nullptr); + int32* OutCount = reinterpret_cast(Stack.MostRecentPropertyAddress); + + P_FINISH; + P_NATIVE_BEGIN; + *static_cast(RESULT_PARAM) = GenericArray_GetMostCommon(ArrayAddr, ArrayProperty, ItemPtr, OutCount); + P_NATIVE_END; + InnerProp->DestroyValue(StorageSpace); + } +}; diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilFunctionLibrary.h b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilFunctionLibrary.h new file mode 100644 index 00000000..a65c5714 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilFunctionLibrary.h @@ -0,0 +1,104 @@ +// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License. + +#pragma once + +#include "CoreMinimal.h" +#include "Kismet/BlueprintFunctionLibrary.h" +#include "DirectiveUtilFunctionLibrary.generated.h" + +/** + * UDirectiveUtilFunctionLibrary + * + * The primary function library for the Directive Utilities plugin. + */ +UCLASS() +class DIRECTIVEUTILITIESRUNTIME_API UDirectiveUtilFunctionLibrary : public UBlueprintFunctionLibrary +{ + GENERATED_BODY() + +public: + + /** + * Returns a list of classes derived from the given base class (not limited to Actors). + * This exposes the built-in GetDerivedClasses function to blueprints. + * @param BaseClass The base class to get the derived classes for. + * @param bRecursive Whether to include derived classes of derived classes. + * @param DerivedClasses The list of derived classes. + */ + UFUNCTION(BlueprintPure, Category = "Directive Utilities|Utility") + static void GetChildClasses(const UClass* BaseClass, bool bRecursive, TArray& DerivedClasses); + + /** + * Copy the provided text to the clipboard. + * @param Text - The text to copy to the clipboard. + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Clipboard" ) + static void CopyTextToClipboard(const FText& Text); + + /** + * Copy the provided string to the clipboard. + * @param String - The string to copy to the clipboard. + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Clipboard" ) + static void CopyStringToClipboard(const FString& String); + + /** + * Get the content from the clipboard as FText. + * @returns The text from the clipboard. + */ + UFUNCTION(BlueprintPure, Category = "Directive Utilities|Clipboard" ) + static FText GetTextFromClipboard(); + + /** + * Get the content from the clipboard as an FString. + * @returns The content from the clipboard as a string. + */ + UFUNCTION(BlueprintPure, Category = "Directive Utilities|Clipboard" ) + static FString GetStringFromClipboard(); + + /** + * Clear the clipboard. + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Clipboard") + static void ClearClipboard(); + + /** + * Get the project version as a string. + * @returns The project version as a string. + */ + UFUNCTION(BlueprintPure, Category = "Directive Utilities|Utility") + static FString GetProjectVersion(); + + /** + * Returns true if the game is running in the Unreal Editor. + * @note This will return false in packaged/standalone builds. + * @returns True if running in the editor. + */ + UFUNCTION(BlueprintPure, Category = "Directive Utilities|Utility") + static bool IsRunningInEditor(); + + /** + * Checks whether a switch (e.g. "MySwitch" matching "-MySwitch") was passed on the + * process command line. Matching is case-insensitive. + * @param Switch - The switch name, without the leading dash. + * @returns True if the switch is present. + */ + UFUNCTION(BlueprintPure, Category = "Directive Utilities|Utility") + static bool HasCommandLineSwitch(const FString& Switch); + + /** + * Reads a key=value option (e.g. "MyKey" matching "-MyKey=Value") from the process + * command line. Matching is case-insensitive; quoted values are returned without the quotes. + * @param Key - The key name, without the leading dash or trailing equals sign. + * @param OutValue - [out] The option's value, or empty if the key is missing. + * @returns True if the key was present. + */ + UFUNCTION(BlueprintPure, Category = "Directive Utilities|Utility") + static bool GetCommandLineOption(const FString& Key, FString& OutValue); + + /** Core of Has Command Line Switch that checks an explicit command line. */ + static bool HasCommandLineSwitch(const TCHAR* CommandLine, const FString& Switch); + + /** Core of Get Command Line Option that reads from an explicit command line. */ + static bool GetCommandLineOption(const TCHAR* CommandLine, const FString& Key, FString& OutValue); +}; diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilGameplayTagFunctionLibrary.h b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilGameplayTagFunctionLibrary.h new file mode 100644 index 00000000..f48d10c3 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilGameplayTagFunctionLibrary.h @@ -0,0 +1,124 @@ +// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License. + +#pragma once + +#include "CoreMinimal.h" +#include "Kismet/BlueprintFunctionLibrary.h" +#include "GameplayTagContainer.h" +#include "DirectiveUtilGameplayTagFunctionLibrary.generated.h" + +/** + * UDirectiveUtilGameplayTagFunctionLibrary + * Hierarchy navigation helpers for Gameplay Tags that the engine does not expose to Blueprints. + */ +UCLASS() +class DIRECTIVEUTILITIESRUNTIME_API UDirectiveUtilGameplayTagFunctionLibrary : public UBlueprintFunctionLibrary +{ + GENERATED_BODY() + +public: + + /** + * Returns the direct (immediate) parent of a tag, e.g. "A.B.C" returns "A.B". + * @param Tag - The tag to read. + * @returns The direct parent tag, or an invalid tag if the tag is a root or invalid. + */ + UFUNCTION(BlueprintPure, Category = "Directive Utilities|GameplayTags") + static FGameplayTag GetTagDirectParent(const FGameplayTag& Tag); + + /** + * Returns all ancestor tags of a tag (its parents, grandparents, etc.), excluding the tag itself. + * e.g. "A.B.C" returns { "A.B", "A" }. + * @param Tag - The tag to read. + * @returns A container of the tag's ancestors. + */ + UFUNCTION(BlueprintPure, Category = "Directive Utilities|GameplayTags", meta = (BlueprintThreadSafe)) + static FGameplayTagContainer GetTagParents(const FGameplayTag& Tag); + + /** + * Returns the number of segments (hierarchy depth) of a tag, e.g. "A.B.C" returns 3. + * @param Tag - The tag to read. + * @returns The depth, or 0 for an invalid tag. + */ + UFUNCTION(BlueprintPure, Category = "Directive Utilities|GameplayTags", meta = (BlueprintThreadSafe)) + static int32 GetTagDepth(const FGameplayTag& Tag); + + /** + * Returns the last (leaf) segment of a tag's name, e.g. "A.B.C" returns "C". + * @param Tag - The tag to read. + * @returns The leaf segment, or an empty string for an invalid tag. + */ + UFUNCTION(BlueprintPure, Category = "Directive Utilities|GameplayTags", meta = (BlueprintThreadSafe)) + static FString GetTagLeafName(const FGameplayTag& Tag); + + /** + * Splits a tag's name into its individual segments, e.g. "A.B.C" returns [ "A", "B", "C" ]. + * @param Tag - The tag to read. + * @returns The segments in order, or an empty array for an invalid tag. + */ + UFUNCTION(BlueprintPure, Category = "Directive Utilities|GameplayTags", meta = (BlueprintThreadSafe)) + static TArray GetTagSegments(const FGameplayTag& Tag); + + /** + * Returns all registered descendant tags of a tag (children, grandchildren, etc.), excluding the tag itself. + * e.g. "A" returns { "A.B", "A.B.C" } when those tags are registered. + * @param Tag - The tag to read. + * @returns A container of the tag's descendants, or an empty container for an invalid tag. + */ + UFUNCTION(BlueprintPure, Category = "Directive Utilities|GameplayTags", meta = (BlueprintThreadSafe)) + static FGameplayTagContainer GetTagChildren(const FGameplayTag& Tag); + + /** + * Returns only the direct (immediate) registered children of a tag, e.g. "A" returns "A.B" but not "A.B.C". + * @param Tag - The tag to read. + * @returns A container of the tag's direct children, or an empty container for an invalid tag. + */ + UFUNCTION(BlueprintPure, Category = "Directive Utilities|GameplayTags", meta = (BlueprintThreadSafe)) + static FGameplayTagContainer GetTagDirectChildren(const FGameplayTag& Tag); + + /** + * Returns the deepest tag that both tags share as an ancestor, e.g. "A.B.C" and "A.B.D" return "A.B". + * The result may be one of the inputs when one tag is an ancestor of the other. + * @param TagA - The first tag. + * @param TagB - The second tag. + * @returns The deepest common ancestor tag, or an invalid tag if the tags share none. + */ + UFUNCTION(BlueprintPure, Category = "Directive Utilities|GameplayTags") + static FGameplayTag GetTagCommonAncestor(const FGameplayTag& TagA, const FGameplayTag& TagB); + + /** + * Truncates a tag to its first Depth segments, e.g. "A.B.C" at depth 2 returns "A.B". + * An ancestor of a registered tag is always registered itself. + * @param Tag - The tag to truncate. + * @param Depth - The number of leading segments to keep. + * @returns The truncated tag, the tag itself when Depth >= its depth, or an invalid tag when Depth < 1 or the tag is invalid. + */ + UFUNCTION(BlueprintPure, Category = "Directive Utilities|GameplayTags") + static FGameplayTag GetTagAtDepth(const FGameplayTag& Tag, int32 Depth); + + /** + * Returns the registered tags that share a tag's direct parent, excluding the tag itself. + * Enumerating the siblings of a root tag (which has no parent) is not supported and returns an empty container. + * @param Tag - The tag to read. + * @returns A container of the tag's siblings, or an empty container for an invalid or root tag. + */ + UFUNCTION(BlueprintPure, Category = "Directive Utilities|GameplayTags") + static FGameplayTagContainer GetTagSiblings(const FGameplayTag& Tag); + + /** + * Checks whether a tag has no registered children. + * @param Tag - The tag to test. + * @returns True if the tag is valid and has no registered descendants; false for an invalid tag. + */ + UFUNCTION(BlueprintPure, Category = "Directive Utilities|GameplayTags", meta = (BlueprintThreadSafe)) + static bool IsLeafTag(const FGameplayTag& Tag); + + /** + * Finds every registered gameplay tag whose name contains Substring (case-insensitive), in registry order. + * Cost scales with the size of the tag registry; intended for tooling and debug use, not per-frame calls. + * @param Substring - The text to search for. An empty string returns an empty array. + * @returns The matching tags. + */ + UFUNCTION(BlueprintPure, Category = "Directive Utilities|GameplayTags") + static TArray FindRegisteredTags(const FString& Substring); +}; diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilInputFunctionLibrary.h b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilInputFunctionLibrary.h new file mode 100644 index 00000000..81ad20b6 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilInputFunctionLibrary.h @@ -0,0 +1,101 @@ +// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License. + +#pragma once + +#include "CoreMinimal.h" +#include "Kismet/BlueprintFunctionLibrary.h" +#include "Types/DirectiveUtilInputTypes.h" +#include "Types/DirectiveUtilTypes.h" +#include "DirectiveUtilInputFunctionLibrary.generated.h" + +class UEnhancedInputLocalPlayerSubsystem; +/** + * UDirectiveUtilInputFunctionLibrary + * A function library that adds functionality for working with input in Unreal Engine. + */ +UCLASS() +class DIRECTIVEUTILITIESRUNTIME_API UDirectiveUtilInputFunctionLibrary : public UBlueprintFunctionLibrary +{ + GENERATED_BODY() + +public: + + /** + * Apply multiple Input Mapping Contexts. + * @param PlayerController The player controller to add the contexts to. Will attempt to get the LocalPlayer from the controller. + * @param Contexts The contexts to apply. + * @param bClearPrevious Whether to clear all previous contexts before applying the new ones. + * @returns Returns Success if the contexts were successfully applied. + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Input", meta=(ExpandEnumAsExecs="ReturnValue", DefaultToSelf="PlayerController")) + static EDirectiveUtilSuccessStatus AddInputMappingContexts( + AController* PlayerController, + const TArray& Contexts, + bool bClearPrevious); + + /** + * Remove multiple Input Mapping Contexts. + * @param PlayerController The player controller to remove the contexts from. Will attempt to get the LocalPlayer from the controller. + * @param Contexts The contexts to remove. + * @returns Returns Success if the contexts were successfully removed. + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Input", meta=(ExpandEnumAsExecs="ReturnValue", DefaultToSelf="PlayerController")) + static EDirectiveUtilSuccessStatus RemoveInputMappingContexts( + AController* PlayerController, + const TArray>& Contexts); + + /** + * Swap a designated Input Mapping Context with a new one. + * If the previous context is found, it will be removed and the new context will be added. + * If the previous context is not found, the new context will be added at the specified priority. + * @param PlayerController The player controller to swap the contexts on. Will attempt to get the LocalPlayer from the controller. + * @param PreviousContext The context to swap out. + * @param NewContext The context to swap in. + * @param Priority The priority to set the new context to. + * @param bUsePreviousPriority Whether to use the previous context's priority when adding the new context. + * @returns Returns Success if the contexts were successfully swapped. + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Input", meta=(ExpandEnumAsExecs="ReturnValue", DefaultToSelf="PlayerController")) + static EDirectiveUtilSuccessStatus SwapInputMappingContexts( + AController* PlayerController, + TSoftObjectPtr PreviousContext, + TSoftObjectPtr NewContext, + int32 Priority, + bool bUsePreviousPriority); + + /** + * Returns the Enhanced Input local player subsystem for the given controller, if available. + * @param PlayerController - The player controller; the Local Player's subsystem is retrieved. + * @returns The Enhanced Input subsystem, or null if the controller has no associated local player subsystem. + */ + UFUNCTION(BlueprintPure, Category = "Directive Utilities|Input", meta=(DefaultToSelf="PlayerController")) + static UEnhancedInputLocalPlayerSubsystem* GetEnhancedInputSubsystem(AController* PlayerController); + + /** + * Returns whether the given input mapping context is currently active on the controller. + * @param PlayerController - The player controller to query. + * @param Context - The input mapping context to check. + * @returns True if the context is currently applied. + */ + UFUNCTION(BlueprintPure, Category = "Directive Utilities|Input", meta=(DefaultToSelf="PlayerController")) + static bool IsInputMappingContextActive(AController* PlayerController, TSoftObjectPtr Context); + + /** + * Removes all input mapping contexts from the controller. + * @param PlayerController - The player controller to clear. + * @returns Success if the contexts were cleared. + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Input", meta=(ExpandEnumAsExecs="ReturnValue", DefaultToSelf="PlayerController")) + static EDirectiveUtilSuccessStatus ClearAllInputMappingContexts(AController* PlayerController); + +protected: + /** + * Attempt to get the Enhanced Input Subsystem from the provided controller. + * @param PlayerController - The player controller to get the subsystem from. + * @param EnhancedInput - The Enhanced Input Subsystem to return + * @return True if the subsystem was found. + */ + static bool TryGetEnhancedInputSubsystemFromController( + AController* PlayerController, + UEnhancedInputLocalPlayerSubsystem*& EnhancedInput); +}; diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilMapFunctionLibrary.h b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilMapFunctionLibrary.h new file mode 100644 index 00000000..b48277ca --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilMapFunctionLibrary.h @@ -0,0 +1,291 @@ +// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License. + +#pragma once + +#include "CoreMinimal.h" +#include "Kismet/BlueprintFunctionLibrary.h" +#include "Net/Core/PushModel/PushModel.h" +#include "DirectiveUtilMapFunctionLibrary.generated.h" + +/** + * UDirectiveUtilMapFunctionLibrary + * A collection of map (TMap) utility functions that improve the usability of maps in Blueprints. + */ +UCLASS() +class DIRECTIVEUTILITIESRUNTIME_API UDirectiveUtilMapFunctionLibrary : public UBlueprintFunctionLibrary +{ + GENERATED_BODY() + +public: + + /** + * Finds the value associated with Key, adding a new default-constructed entry if the key is not present. + * @param TargetMap - The map to search or add to. + * @param Key - The key to look up. + * @param Value - [out] A copy of the existing or newly added value. + */ + UFUNCTION(BlueprintCallable, CustomThunk, meta=(DisplayName = "Find Or Add", CompactNodeTitle = "FIND OR ADD", MapParam = "TargetMap", MapKeyParam = "Key", MapValueParam = "Value", AutoCreateRefTerm = "Key, Value"), Category="Directive Utilities|Map") + static void Map_FindOrAdd(const TMap& TargetMap, const int32& Key, int32& Value); + + /** + * Resets every value in the map to its default while preserving all keys. + * @param TargetMap - The map whose values will be reset. + */ + UFUNCTION(BlueprintCallable, CustomThunk, meta=(DisplayName = "Clear Values", CompactNodeTitle = "CLEAR VALUES", MapParam = "TargetMap"), Category="Directive Utilities|Map") + static void Map_ClearValues(const TMap& TargetMap); + + /** + * Gathers every key whose value is identical to Value, in map order. + * @param TargetMap - The map to search. + * @param Value - The value to look for. + * @param Keys - [out] Every key associated with Value. + */ + UFUNCTION(BlueprintPure, CustomThunk, meta=(DisplayName = "Get Keys By Value", CompactNodeTitle = "KEYS BY VALUE", MapParam = "TargetMap", MapValueParam = "Value", MapKeyParam = "Keys", AutoCreateRefTerm = "Value, Keys"), Category="Directive Utilities|Map") + static void Map_GetKeysByValue(const TMap& TargetMap, const int32& Value, TArray& Keys); + + /** + * Checks whether any value in the map is identical to Value. + * @param TargetMap - The map to search. + * @param Value - The value to look for. + * @returns True if at least one entry holds Value. + */ + UFUNCTION(BlueprintPure, CustomThunk, meta=(DisplayName = "Has Value", CompactNodeTitle = "HAS VALUE", MapParam = "TargetMap", MapValueParam = "Value", AutoCreateRefTerm = "Value"), Category="Directive Utilities|Map") + static bool Map_HasValue(const TMap& TargetMap, const int32& Value); + + /** + * Removes every key in Keys from the map. + * @param TargetMap - The map to remove from. + * @param Keys - The keys to remove. + * @returns The number of entries that were actually removed. + */ + UFUNCTION(BlueprintCallable, CustomThunk, meta=(DisplayName = "Remove Keys", CompactNodeTitle = "REMOVE KEYS", MapParam = "TargetMap", MapKeyParam = "Keys", AutoCreateRefTerm = "Keys"), Category="Directive Utilities|Map") + static int32 Map_RemoveKeys(const TMap& TargetMap, const TArray& Keys); + + /** + * Copies every pair from SourceMap into TargetMap. Both maps must share key and value types. + * @param TargetMap - The map to copy into. + * @param SourceMap - The map to copy from. + * @param bOverwriteExisting - If true, keys already present in TargetMap are overwritten with SourceMap's values. + */ + UFUNCTION(BlueprintCallable, CustomThunk, meta=(DisplayName = "Append", CompactNodeTitle = "APPEND", MapParam = "TargetMap|SourceMap"), Category="Directive Utilities|Map") + static void Map_Append(const TMap& TargetMap, const TMap& SourceMap, bool bOverwriteExisting = true); + + /*~ + * Native functions called by the custom thunk layers below, which read off the property address + * and operate on the underlying map. Based off UBlueprintMapLibrary implementation. + ~*/ + + static void GenericMap_FindOrAdd(const void* TargetMap, const FMapProperty* MapProperty, const void* KeyPtr, void* ValuePtr); + static void GenericMap_ClearValues(const void* TargetMap, const FMapProperty* MapProperty); + static void GenericMap_GetKeysByValue(const void* TargetMap, const FMapProperty* MapProperty, const void* ValuePtr, const void* TargetArray, const FArrayProperty* ArrayProperty); + static bool GenericMap_HasValue(const void* TargetMap, const FMapProperty* MapProperty, const void* ValuePtr); + static int32 GenericMap_RemoveKeys(const void* TargetMap, const FMapProperty* MapProperty, const void* TargetArray, const FArrayProperty* ArrayProperty); + static void GenericMap_Append(const void* TargetMap, const FMapProperty* TargetMapProperty, const void* SourceMap, const FMapProperty* SourceMapProperty, bool bOverwriteExisting); + + /*~ + * Custom thunk layers that read off the property address and call the appropriate native handler. + * Based off UBlueprintMapLibrary implementation. + ~*/ + + DECLARE_FUNCTION(execMap_FindOrAdd) + { + Stack.MostRecentProperty = nullptr; + Stack.StepCompiledIn(nullptr); + void* MapAddr = Stack.MostRecentPropertyAddress; + FMapProperty* MapProperty = CastField(Stack.MostRecentProperty); + if (!MapProperty) + { + Stack.bArrayContextFailed = true; + return; + } + + const FProperty* CurrKeyProp = MapProperty->KeyProp; + const int32 KeyPropertySize = CurrKeyProp->GetElementSize() * CurrKeyProp->ArrayDim; + void* KeyStorageSpace = FMemory_Alloca(KeyPropertySize); + CurrKeyProp->InitializeValue(KeyStorageSpace); + + Stack.MostRecentPropertyAddress = nullptr; + Stack.MostRecentPropertyContainer = nullptr; + Stack.StepCompiledIn(KeyStorageSpace); + + const FProperty* CurrValueProp = MapProperty->ValueProp; + const int32 ValuePropertySize = CurrValueProp->GetElementSize() * CurrValueProp->ArrayDim; + void* ValueStorageSpace = FMemory_Alloca(ValuePropertySize); + CurrValueProp->InitializeValue(ValueStorageSpace); + + Stack.MostRecentPropertyAddress = nullptr; + Stack.MostRecentPropertyContainer = nullptr; + Stack.StepCompiledIn(ValueStorageSpace); + void* ItemPtr; + if (Stack.MostRecentPropertyAddress != nullptr && Stack.MostRecentProperty != nullptr + && ValuePropertySize == Stack.MostRecentProperty->GetElementSize() * Stack.MostRecentProperty->ArrayDim + && (Stack.MostRecentProperty->GetClass()->IsChildOf(CurrValueProp->GetClass()) + || CurrValueProp->GetClass()->IsChildOf(Stack.MostRecentProperty->GetClass()))) + { + ItemPtr = Stack.MostRecentPropertyAddress; + } + else + { + ItemPtr = ValueStorageSpace; + } + + P_FINISH; + P_NATIVE_BEGIN; + MARK_PROPERTY_DIRTY(Stack.Object, MapProperty); + GenericMap_FindOrAdd(MapAddr, MapProperty, KeyStorageSpace, ItemPtr); + P_NATIVE_END; + + CurrValueProp->DestroyValue(ValueStorageSpace); + CurrKeyProp->DestroyValue(KeyStorageSpace); + } + + DECLARE_FUNCTION(execMap_ClearValues) + { + Stack.MostRecentProperty = nullptr; + Stack.StepCompiledIn(nullptr); + void* MapAddr = Stack.MostRecentPropertyAddress; + FMapProperty* MapProperty = CastField(Stack.MostRecentProperty); + if (!MapProperty) + { + Stack.bArrayContextFailed = true; + return; + } + + P_FINISH; + P_NATIVE_BEGIN; + MARK_PROPERTY_DIRTY(Stack.Object, MapProperty); + GenericMap_ClearValues(MapAddr, MapProperty); + P_NATIVE_END; + } + + DECLARE_FUNCTION(execMap_GetKeysByValue) + { + Stack.MostRecentProperty = nullptr; + Stack.StepCompiledIn(nullptr); + void* MapAddr = Stack.MostRecentPropertyAddress; + FMapProperty* MapProperty = CastField(Stack.MostRecentProperty); + if (!MapProperty) + { + Stack.bArrayContextFailed = true; + return; + } + + const FProperty* CurrValueProp = MapProperty->ValueProp; + const int32 ValuePropertySize = CurrValueProp->GetElementSize() * CurrValueProp->ArrayDim; + void* ValueStorageSpace = FMemory_Alloca(ValuePropertySize); + CurrValueProp->InitializeValue(ValueStorageSpace); + + Stack.MostRecentPropertyAddress = nullptr; + Stack.MostRecentPropertyContainer = nullptr; + Stack.StepCompiledIn(ValueStorageSpace); + + Stack.MostRecentProperty = nullptr; + Stack.StepCompiledIn(nullptr); + void* ArrayAddr = Stack.MostRecentPropertyAddress; + FArrayProperty* ArrayProperty = CastField(Stack.MostRecentProperty); + if (!ArrayProperty || !ArrayProperty->Inner->SameType(MapProperty->KeyProp)) + { + Stack.bArrayContextFailed = true; + CurrValueProp->DestroyValue(ValueStorageSpace); + return; + } + + P_FINISH; + P_NATIVE_BEGIN; + GenericMap_GetKeysByValue(MapAddr, MapProperty, ValueStorageSpace, ArrayAddr, ArrayProperty); + P_NATIVE_END; + + CurrValueProp->DestroyValue(ValueStorageSpace); + } + + DECLARE_FUNCTION(execMap_HasValue) + { + Stack.MostRecentProperty = nullptr; + Stack.StepCompiledIn(nullptr); + void* MapAddr = Stack.MostRecentPropertyAddress; + FMapProperty* MapProperty = CastField(Stack.MostRecentProperty); + if (!MapProperty) + { + Stack.bArrayContextFailed = true; + return; + } + + const FProperty* CurrValueProp = MapProperty->ValueProp; + const int32 ValuePropertySize = CurrValueProp->GetElementSize() * CurrValueProp->ArrayDim; + void* ValueStorageSpace = FMemory_Alloca(ValuePropertySize); + CurrValueProp->InitializeValue(ValueStorageSpace); + + Stack.MostRecentPropertyAddress = nullptr; + Stack.MostRecentPropertyContainer = nullptr; + Stack.StepCompiledIn(ValueStorageSpace); + + P_FINISH; + P_NATIVE_BEGIN; + *static_cast(RESULT_PARAM) = GenericMap_HasValue(MapAddr, MapProperty, ValueStorageSpace); + P_NATIVE_END; + + CurrValueProp->DestroyValue(ValueStorageSpace); + } + + DECLARE_FUNCTION(execMap_RemoveKeys) + { + Stack.MostRecentProperty = nullptr; + Stack.StepCompiledIn(nullptr); + void* MapAddr = Stack.MostRecentPropertyAddress; + FMapProperty* MapProperty = CastField(Stack.MostRecentProperty); + if (!MapProperty) + { + Stack.bArrayContextFailed = true; + return; + } + + Stack.MostRecentProperty = nullptr; + Stack.StepCompiledIn(nullptr); + void* ArrayAddr = Stack.MostRecentPropertyAddress; + FArrayProperty* ArrayProperty = CastField(Stack.MostRecentProperty); + if (!ArrayProperty || !ArrayProperty->Inner->SameType(MapProperty->KeyProp)) + { + Stack.bArrayContextFailed = true; + return; + } + + P_FINISH; + P_NATIVE_BEGIN; + MARK_PROPERTY_DIRTY(Stack.Object, MapProperty); + *static_cast(RESULT_PARAM) = GenericMap_RemoveKeys(MapAddr, MapProperty, ArrayAddr, ArrayProperty); + P_NATIVE_END; + } + + DECLARE_FUNCTION(execMap_Append) + { + Stack.MostRecentProperty = nullptr; + Stack.StepCompiledIn(nullptr); + void* TargetMapAddr = Stack.MostRecentPropertyAddress; + FMapProperty* TargetMapProperty = CastField(Stack.MostRecentProperty); + if (!TargetMapProperty) + { + Stack.bArrayContextFailed = true; + return; + } + + Stack.MostRecentProperty = nullptr; + Stack.StepCompiledIn(nullptr); + void* SourceMapAddr = Stack.MostRecentPropertyAddress; + FMapProperty* SourceMapProperty = CastField(Stack.MostRecentProperty); + if (!SourceMapProperty + || !SourceMapProperty->KeyProp->SameType(TargetMapProperty->KeyProp) + || !SourceMapProperty->ValueProp->SameType(TargetMapProperty->ValueProp)) + { + Stack.bArrayContextFailed = true; + return; + } + + P_GET_UBOOL(bOverwriteExisting); + + P_FINISH; + P_NATIVE_BEGIN; + MARK_PROPERTY_DIRTY(Stack.Object, TargetMapProperty); + GenericMap_Append(TargetMapAddr, TargetMapProperty, SourceMapAddr, SourceMapProperty, bOverwriteExisting); + P_NATIVE_END; + } +}; diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilMathFunctionLibrary.h b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilMathFunctionLibrary.h new file mode 100644 index 00000000..665689e7 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilMathFunctionLibrary.h @@ -0,0 +1,244 @@ +// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License. + +#pragma once + +#include "CoreMinimal.h" +#include "Kismet/BlueprintFunctionLibrary.h" +#include "Types/DirectiveUtilMathTypes.h" +#include "DirectiveUtilMathFunctionLibrary.generated.h" + +/** + * UDirectiveUtilMathFunctionLibrary + * + * Contains math functions for the Directive Utilities plugin. + */ +UCLASS() +class DIRECTIVEUTILITIESRUNTIME_API UDirectiveUtilMathFunctionLibrary : public UBlueprintFunctionLibrary +{ + GENERATED_BODY() + +public: + + /** + * Returns a perlin noise value between -1 and 1 at the given position. + * @note This exposes the built-in PerlinNoise2D function to blueprints. + * @param Position - The position to get the noise value for. + * @returns The noise value at the given position. + */ + UFUNCTION(BlueprintPure, meta = (BlueprintThreadSafe), Category = "Directive Utilities|Math|Random") + static float PerlinNoise2D(FVector2D Position); + + /** + * Returns a perlin noise value between -1 and 1 at the given position. + * @note This exposes the built-in PerlinNoise3D function to blueprints. + * @param Position - The position to get the noise value for. + * @returns The noise value at the given position. + */ + UFUNCTION(BlueprintPure, meta = (BlueprintThreadSafe), Category = "Directive Utilities|Math|Random") + static float PerlinNoise3D(const FVector& Position); + + /** + * Returns the angle in degrees between two vectors. + * @param A - The first vector. + * @param B - The second vector. + * @returns The angle between the two vectors in degrees. + */ + UFUNCTION(BlueprintPure, meta = (BlueprintThreadSafe), Category = "Directive Utilities|Math|Vector") + static float AngleBetweenVectors(const FVector& A, const FVector& B); + + /** + * Applies a Back/Elastic/Bounce easing curve to a normalized alpha. + * @note These are the Penner easing curves the engine's built-in "Ease" node (EEasingFunc) does not provide. + * For Sinusoidal/Exponential/Circular/power easings, use the engine's "Ease" node instead. + * @param Alpha - The input alpha. Clamped to the [0, 1] range. + * @param EaseType - The easing curve to apply. + * @returns The eased alpha. Note that Back and Elastic curves intentionally overshoot the [0, 1] range. + */ + UFUNCTION(BlueprintPure, meta = (DisplayName = "Ease Alpha", BlueprintThreadSafe), Category = "Directive Utilities|Math|Easing") + static float EaseAlpha(float Alpha, EDirectiveUtilEaseType EaseType); + + /** + * Eases a float from A to B using a Back/Elastic/Bounce easing curve. + * @param A - The start value (returned at Alpha 0). + * @param B - The target value (returned at Alpha 1). + * @param Alpha - The input alpha. Clamped to the [0, 1] range. + * @param EaseType - The easing curve to apply. + * @returns The eased value between A and B. + */ + UFUNCTION(BlueprintPure, meta = (DisplayName = "Ease (Float)", BlueprintThreadSafe), Category = "Directive Utilities|Math|Easing") + static float EaseFloat(float A, float B, float Alpha, EDirectiveUtilEaseType EaseType); + + /** + * Eases a vector from A to B using a Back/Elastic/Bounce easing curve. + * @param A - The start vector (returned at Alpha 0). + * @param B - The target vector (returned at Alpha 1). + * @param Alpha - The input alpha. Clamped to the [0, 1] range. + * @param EaseType - The easing curve to apply. + * @returns The eased vector between A and B. + */ + UFUNCTION(BlueprintPure, meta = (DisplayName = "Ease (Vector)", BlueprintThreadSafe), Category = "Directive Utilities|Math|Easing") + static FVector EaseVector(const FVector& A, const FVector& B, float Alpha, EDirectiveUtilEaseType EaseType); + + /** + * Eases a rotator from A to B using a Back/Elastic/Bounce easing curve (shortest-path interpolation). + * @param A - The start rotator (returned at Alpha 0). + * @param B - The target rotator (returned at Alpha 1). + * @param Alpha - The input alpha. Clamped to the [0, 1] range. + * @param EaseType - The easing curve to apply. + * @returns The eased rotator between A and B. + */ + UFUNCTION(BlueprintPure, meta = (DisplayName = "Ease (Rotator)", BlueprintThreadSafe), Category = "Directive Utilities|Math|Easing") + static FRotator EaseRotator(const FRotator& A, const FRotator& B, float Alpha, EDirectiveUtilEaseType EaseType); + + /** + * Eases a color from A to B using a Back/Elastic/Bounce easing curve. + * @param A - The start color (returned at Alpha 0). + * @param B - The target color (returned at Alpha 1). + * @param Alpha - The input alpha. Clamped to the [0, 1] range. + * @param EaseType - The easing curve to apply. + * @returns The eased color between A and B. + */ + UFUNCTION(BlueprintPure, meta = (DisplayName = "Ease (Color)", BlueprintThreadSafe), Category = "Directive Utilities|Math|Easing") + static FLinearColor EaseColor(const FLinearColor& A, const FLinearColor& B, float Alpha, EDirectiveUtilEaseType EaseType); + + /** + * Rounds a float to a given number of decimal places. Rounds half away from zero, + * matching "Round To Decimals (Text)". + * @note Due to floating-point representation the returned value may not display exactly; + * use "Round To Decimals (Text)" for clean display. + * @param Value - The value to round. + * @param Decimals - The number of decimal places to round to. Clamped to the [0, 10] range. + * @returns The rounded value. + */ + UFUNCTION(BlueprintPure, meta = (DisplayName = "Round To Decimals", BlueprintThreadSafe), Category = "Directive Utilities|Math|Float") + static float RoundToDecimals(float Value, int32 Decimals); + + /** + * Rounds a float to a given number of decimal places and returns it as display text. + * Rounds half away from zero, matching "Round To Decimals". + * @param Value - The value to round. + * @param Decimals - The maximum number of decimal places to display. Clamped to the [0, 10] range. + * @returns The rounded value as text, formatted with the current locale. + */ + UFUNCTION(BlueprintPure, meta = (DisplayName = "Round To Decimals (Text)", BlueprintThreadSafe), Category = "Directive Utilities|Math|Float") + static FText RoundToDecimalsAsText(float Value, int32 Decimals); + + /** + * Formats a byte count as a human-readable size using binary units (1024): B, KB, MB, GB, TB, PB. + * Decimals are applied only from KB up ("532 B", "1.4 MB"). Negative input formats the absolute + * value with a leading minus sign. Output is English-only. + * @param Bytes - The byte count to format. + * @param Decimals - The number of decimal places to show from KB up. Clamped to the [0, 3] range. + * @returns The formatted size text. + */ + UFUNCTION(BlueprintPure, Category = "Directive Utilities|Math|Formatting") + static FText FormatBytes(int64 Bytes, int32 Decimals = 1); + + /** + * Formats a duration in seconds as d/h/m/s units from the largest nonzero unit down, with + * two-digit padding after the first ("1h 03m 05s", "2d 04h", "45s"). With bIncludeSeconds + * false the seconds unit is dropped and sub-minute durations return "0m". Negative input gets + * a leading minus sign; non-finite input returns "0s". Output is English-only. + * @param Seconds - The duration in seconds. + * @param bIncludeSeconds - Whether to include the seconds unit. + * @returns The formatted duration text. + */ + UFUNCTION(BlueprintPure, Category = "Directive Utilities|Math|Formatting") + static FText FormatDuration(float Seconds, bool bIncludeSeconds = true); + + /** + * Formats a timestamp relative to the current local time: "just now" (under a minute), + * "N minute(s)/hour(s)/day(s) ago", or "in N ..." for future timestamps. Uses local time, + * pairing with Get Save Slot Timestamp. Output is English-only. + * @note Not pure: reads the current clock each call. + * @param Timestamp - The local timestamp to describe. + * @returns The formatted relative time text. + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Math|Formatting") + static FText FormatRelativeTime(const FDateTime& Timestamp); + + /** + * Returns the sum of an integer array as a 64-bit integer, so large arrays cannot overflow int32. + * @param Values - The values to sum. + * @returns The sum of the values, or 0 if the array is empty. + */ + UFUNCTION(BlueprintPure, meta = (BlueprintThreadSafe), Category = "Directive Utilities|Math|Array") + static int64 GetIntArraySum(const TArray& Values); + + /** + * Returns the arithmetic mean of an integer array. + * @param Values - The values to average. + * @returns The average of the values, or 0 if the array is empty. + */ + UFUNCTION(BlueprintPure, meta = (BlueprintThreadSafe), Category = "Directive Utilities|Math|Array") + static float GetIntArrayAverage(const TArray& Values); + + /** + * Returns the median of an integer array (computed on a sorted copy; the input is not modified). + * For an even count, returns the average of the two middle values. + * @param Values - The values to take the median of. + * @returns The median of the values, or 0 if the array is empty. + */ + UFUNCTION(BlueprintPure, meta = (BlueprintThreadSafe), Category = "Directive Utilities|Math|Array") + static float GetIntArrayMedian(const TArray& Values); + + /** + * Returns the population standard deviation of an integer array (divides by N, not N-1). + * @param Values - The values to measure. + * @returns The population standard deviation, or 0 if the array is empty. + */ + UFUNCTION(BlueprintPure, meta = (BlueprintThreadSafe), Category = "Directive Utilities|Math|Array") + static float GetIntArrayStandardDeviation(const TArray& Values); + + /** + * Returns the sum of a float array. Accumulates in double internally for precision. + * @param Values - The values to sum. + * @returns The sum of the values, or 0 if the array is empty. + */ + UFUNCTION(BlueprintPure, meta = (BlueprintThreadSafe), Category = "Directive Utilities|Math|Array") + static float GetFloatArraySum(const TArray& Values); + + /** + * Returns the arithmetic mean of a float array. Accumulates in double internally for precision. + * @param Values - The values to average. + * @returns The average of the values, or 0 if the array is empty. + */ + UFUNCTION(BlueprintPure, meta = (BlueprintThreadSafe), Category = "Directive Utilities|Math|Array") + static float GetFloatArrayAverage(const TArray& Values); + + /** + * Returns the median of a float array (computed on a sorted copy; the input is not modified). + * For an even count, returns the average of the two middle values. + * @param Values - The values to take the median of. + * @returns The median of the values, or 0 if the array is empty. + */ + UFUNCTION(BlueprintPure, meta = (BlueprintThreadSafe), Category = "Directive Utilities|Math|Array") + static float GetFloatArrayMedian(const TArray& Values); + + /** + * Returns the population standard deviation of a float array (divides by N, not N-1). + * Accumulates in double internally for precision. + * @param Values - The values to measure. + * @returns The population standard deviation, or 0 if the array is empty. + */ + UFUNCTION(BlueprintPure, meta = (BlueprintThreadSafe), Category = "Directive Utilities|Math|Array") + static float GetFloatArrayStandardDeviation(const TArray& Values); + + /** + * Returns a random index into the Weights array, where each index's probability is proportional to its weight. + * Useful for loot tables and weighted spawning. Negative weights are treated as zero. + * @param Weights - The per-index weights. + * @returns The selected index, or INDEX_NONE (-1) if the array is empty or all weights are zero. + */ + UFUNCTION(BlueprintCallable, meta = (DisplayName = "Get Random Index From Weights"), Category = "Directive Utilities|Math|Random") + static int32 GetRandomIndexFromWeights(const TArray& Weights); + + /** + * Deterministic version of Get Random Index From Weights that draws from (and advances) the provided random stream. + * @param Stream - The random stream to draw from. + * @param Weights - The per-index weights. Negative weights are treated as zero. + * @returns The selected index, or INDEX_NONE (-1) if the array is empty or all weights are zero. + */ + UFUNCTION(BlueprintCallable, meta = (DisplayName = "Get Random Index From Weights (Stream)"), Category = "Directive Utilities|Math|Random") + static int32 GetRandomIndexFromWeightsFromStream(UPARAM(ref) FRandomStream& Stream, const TArray& Weights); +}; diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilRegexFunctionLibrary.h b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilRegexFunctionLibrary.h new file mode 100644 index 00000000..0a39c4c4 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilRegexFunctionLibrary.h @@ -0,0 +1,80 @@ +// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License. + +#pragma once + +#include "CoreMinimal.h" +#include "Kismet/BlueprintFunctionLibrary.h" +#include "DirectiveUtilRegexFunctionLibrary.generated.h" + +/** + * UDirectiveUtilRegexFunctionLibrary + * Exposes the engine's regular-expression matching (FRegexPattern/FRegexMatcher) to Blueprints and Python. + * @warning Complex patterns with nested quantifiers can be extremely slow on long inputs + * (catastrophic backtracking), and matching runs synchronously on the calling thread. + */ +UCLASS() +class DIRECTIVEUTILITIESRUNTIME_API UDirectiveUtilRegexFunctionLibrary : public UBlueprintFunctionLibrary +{ + GENERATED_BODY() + +public: + + /** + * Returns true if the pattern matches anywhere within the input string. + * @param Input - The string to search. + * @param Pattern - The regular expression pattern. + * @param bCaseSensitive - Whether matching is case-sensitive. Defaults to true. + * @returns True if at least one match was found. + */ + UFUNCTION(BlueprintPure, Category = "Directive Utilities|Regex") + static bool RegexMatches(const FString& Input, const FString& Pattern, bool bCaseSensitive = true); + + /** + * Finds the first match of the pattern within the input string. + * @param Input - The string to search. + * @param Pattern - The regular expression pattern. + * @param OutMatch - [out] The matched substring, or empty if no match. + * @param OutMatchStart - [out] The start index of the match, or INDEX_NONE if no match. + * @param OutMatchEnd - [out] The index just past the end of the match, or INDEX_NONE if no match. + * @param bCaseSensitive - Whether matching is case-sensitive. Defaults to true. + * @returns True if a match was found. + */ + UFUNCTION(BlueprintPure, Category = "Directive Utilities|Regex") + static bool RegexFindFirst(const FString& Input, const FString& Pattern, FString& OutMatch, int32& OutMatchStart, int32& OutMatchEnd, bool bCaseSensitive = true); + + /** + * Returns every (non-overlapping) match of the pattern within the input string. + * @note Zero-width (empty) matches are ignored. + * @param Input - The string to search. + * @param Pattern - The regular expression pattern. + * @param bCaseSensitive - Whether matching is case-sensitive. Defaults to true. + * @returns The matched substrings, in order. + */ + UFUNCTION(BlueprintPure, Category = "Directive Utilities|Regex") + static TArray RegexFindAll(const FString& Input, const FString& Pattern, bool bCaseSensitive = true); + + /** + * Replaces every match of the pattern in the input string with a literal replacement. + * @note The replacement is literal text; capture-group back-references (e.g. $1) are not expanded. + * @note Zero-width (empty) matches are ignored. + * @param Input - The string to operate on. + * @param Pattern - The regular expression pattern. + * @param Replacement - The literal text to substitute for each match. + * @param bCaseSensitive - Whether matching is case-sensitive. Defaults to true. + * @returns The string with all matches replaced. + */ + UFUNCTION(BlueprintPure, Category = "Directive Utilities|Regex") + static FString RegexReplaceAll(const FString& Input, const FString& Pattern, const FString& Replacement, bool bCaseSensitive = true); + + /** + * Returns a specific capture group from the first match of the pattern. + * @param Input - The string to search. + * @param Pattern - The regular expression pattern. + * @param GroupIndex - The capture group to retrieve. 0 is the entire match; 1+ are the parenthesized groups. + * @param OutGroup - [out] The captured substring, or empty if the group did not participate in the match. + * @param bCaseSensitive - Whether matching is case-sensitive. Defaults to true. + * @returns True if a match was found and the requested group participated in it. + */ + UFUNCTION(BlueprintPure, Category = "Directive Utilities|Regex") + static bool RegexGetCaptureGroup(const FString& Input, const FString& Pattern, int32 GroupIndex, FString& OutGroup, bool bCaseSensitive = true); +}; diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilSaveGameFunctionLibrary.h b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilSaveGameFunctionLibrary.h new file mode 100644 index 00000000..8154eed4 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilSaveGameFunctionLibrary.h @@ -0,0 +1,93 @@ +// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License. + +#pragma once + +#include "CoreMinimal.h" +#include "Kismet/BlueprintFunctionLibrary.h" +#include "DirectiveUtilSaveGameFunctionLibrary.generated.h" + +class USaveGame; + +/** + * UDirectiveUtilSaveGameFunctionLibrary + * Save-slot utilities that fill the gaps left by UGameplayStatics: enumerating slots, reading slot + * timestamps, and serializing a save object to/from an in-memory byte array. This is slot/IO QoL only, + * not a save framework: use the engine's SaveGameToSlot/LoadGameFromSlot for the actual slot I/O. + */ +UCLASS() +class DIRECTIVEUTILITIESRUNTIME_API UDirectiveUtilSaveGameFunctionLibrary : public UBlueprintFunctionLibrary +{ + GENERATED_BODY() + +public: + + /** + * Returns the names of all existing save slots in the project's default save directory. + * @note This enumerates the engine's default file-based save directory (Saved/SaveGames); it does not + * cover platform-specific save systems (e.g. console storage). + * @returns The save slot names (without extension). + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|SaveGame") + static TArray GetAllSaveSlotNames(); + + /** + * Returns the last-modified timestamp of a save slot, if it exists. + * @param SlotName - The save slot name. + * @param OutTimestamp - [out] The slot's last-modified time (local), or a default time if it does not exist. + * Converted from the file system's UTC timestamp to local time. + * @returns True if the slot exists. + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|SaveGame") + static bool GetSaveSlotTimestamp(const FString& SlotName, FDateTime& OutTimestamp); + + /** + * Serializes a save game object to an in-memory byte array (instead of a slot file). + * Useful for custom storage, networking, or cloud saves. + * @param SaveGameObject - The save game object to serialize. + * @param OutBytes - [out] The serialized bytes. + * @returns True if serialization succeeded. + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|SaveGame") + static bool SaveGameToBytes(USaveGame* SaveGameObject, TArray& OutBytes); + + /** + * Deserializes a save game object from an in-memory byte array produced by Save Game To Bytes. + * @param SaveData - The serialized bytes. + * @returns The deserialized save game object, or null on failure. + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|SaveGame") + static USaveGame* LoadGameFromBytes(const TArray& SaveData); + + /** + * Checks whether a save slot exists. Goes through the engine's save game system, so unlike + * enumeration it also works on platform save backends. + * @param SlotName - The save slot name. + * @param UserIndex - The platform user index the save belongs to. + * @returns True if the slot exists. + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|SaveGame") + static bool DoesSaveSlotExist(const FString& SlotName, int32 UserIndex = 0); + + /** + * Deletes a save slot. Goes through the engine's save game system, so unlike enumeration it + * also works on platform save backends. + * @param SlotName - The save slot name. + * @param UserIndex - The platform user index the save belongs to. + * @returns True if a save was actually deleted. + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|SaveGame") + static bool DeleteSaveSlot(const FString& SlotName, int32 UserIndex = 0); + + /** + * Renames a save slot by copying its data to the new name and then deleting the original. + * Fails without mutating anything unless both names are valid, the names differ, the old slot + * exists, and the new slot does not. On failure the original slot is never lost. Goes through + * the engine's save game system, so unlike enumeration it also works on platform save backends. + * @param OldSlotName - The existing save slot name. + * @param NewSlotName - The new save slot name. + * @param UserIndex - The platform user index the save belongs to. + * @returns True if the slot was renamed. + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|SaveGame") + static bool RenameSaveSlot(const FString& OldSlotName, const FString& NewSlotName, int32 UserIndex = 0); +}; diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilStringFunctionLibrary.h b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilStringFunctionLibrary.h new file mode 100644 index 00000000..435ba310 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilStringFunctionLibrary.h @@ -0,0 +1,339 @@ +// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License. + +#pragma once + +#include "CoreMinimal.h" +#include "Kismet/BlueprintFunctionLibrary.h" +#include "Types/DirectiveUtilTypes.h" +#include "DirectiveUtilStringFunctionLibrary.generated.h" + +/** + * UDirectiveUtilStringFunctionLibrary + * A collection of helpful string utility functions that improve the usability of strings in Blueprints. + */ +UCLASS() +class DIRECTIVEUTILITIESRUNTIME_API UDirectiveUtilStringFunctionLibrary : public UBlueprintFunctionLibrary +{ + GENERATED_BODY() + +public: + + /** + * Detect if the provided string contains any letters. + * @note Operates on ASCII characters only; non-ASCII letters/digits are not classified or cased. + * @param String - The string to check. + * @returns True if the string contains letters. + */ + UFUNCTION(BlueprintPure, Category = "Directive Utilities|String" ) + static bool ContainsLetters(const FString& String); + + /** + * Detect if the provided string contains any numbers. + * @note Operates on ASCII characters only; non-ASCII letters/digits are not classified or cased. + * @param String - The string to check. + * @returns True if the string contains numbers. + */ + UFUNCTION(BlueprintPure, Category = "Directive Utilities|String" ) + static bool ContainsNumbers(const FString& String); + + /** + * Detect if the provided string contains any spaces. + * @param String - The string to check. + * @returns True if the string contains spaces. + */ + UFUNCTION(BlueprintPure, Category = "Directive Utilities|String" ) + static bool ContainsSpaces(const FString& String); + + /** + * Detect if the provided string contains any special characters. + * @note Operates on ASCII characters only; non-ASCII letters/digits are not classified or cased. + * @param String - The string to check. + * @returns True if the string contains special characters. + */ + UFUNCTION(BlueprintPure, Category = "Directive Utilities|String" ) + static bool ContainsSpecialCharacters(const FString& String); + + /** + * Filter out characters types from the string. + * @note Operates on ASCII characters only; non-ASCII letters/digits are not classified or cased. + * @param String - The string to filter. + * @param bLetters - If true, filter out letters. + * @param bNumbers - If true, filter out numbers. + * @param bSpecialCharacters - If true, filter out special characters. + * @param bSpaces - If true, filter out spaces. + */ + UFUNCTION(BlueprintPure, Category = "Directive Utilities|String" ) + static FString FilterCharacters(const FString& String, const bool bLetters, const bool bNumbers, const bool bSpecialCharacters, const bool bSpaces); + + /** + * Sort a string array alphabetically. + * @param StringArray - The string array to sort. + */ + UFUNCTION(BlueprintPure, Category = "Directive Utilities|String", meta = (DeprecatedFunction, DeprecationMessage = "Use the engine's Sort String Array node (UE 5.6+) instead.")) + static TArray SortStringArray(TArray StringArray); + + /** + * Truncates a string to the specified length and appends a suffix. + * @param String - The string to truncate. + * @param MaxLength - The maximum length of the resulting string including the suffix. If MaxLength is smaller than the suffix length, the full suffix is still returned. + * @param Suffix - The suffix to append when truncated. + * @returns The truncated string. + */ + UFUNCTION(BlueprintPure, Category = "Directive Utilities|String") + static FString TruncateString(const FString& String, int32 MaxLength, const FString& Suffix = TEXT("...")); + + /** + * Converts a string to title case, capitalizing the first letter of each word. + * @note Operates on ASCII characters only; non-ASCII letters/digits are not classified or cased. + * @param String - The string to convert. + * @returns The title-cased string. + */ + UFUNCTION(BlueprintPure, Category = "Directive Utilities|String") + static FString ToTitleCase(const FString& String); + + /** + * Splits a string into its component words. Words are delimited by any non-alphanumeric + * character (which is consumed), a lower-to-upper transition ("fooBar" -> "foo", "Bar"), + * the end of an acronym ("XMLParser" -> "XML", "Parser"), or a letter/digit transition + * ("version2Beta" -> "version", "2", "Beta"). + * @note Operates on ASCII characters only; non-ASCII letters/digits are not classified or cased. + * @param String - The string to split. + * @returns The words in order, or an empty array for an empty input. + */ + UFUNCTION(BlueprintPure, Category = "Directive Utilities|String") + static TArray SplitIntoWords(const FString& String); + + /** + * Converts a string to camelCase: the first word lowercased, every following word capitalized, + * joined without separators ("my var name" -> "myVarName"). + * @note Operates on ASCII characters only; non-ASCII letters/digits are not classified or cased. + * @param String - The string to convert. + * @returns The camelCased string. + */ + UFUNCTION(BlueprintPure, Category = "Directive Utilities|String") + static FString ToCamelCase(const FString& String); + + /** + * Converts a string to PascalCase: every word capitalized, joined without separators + * ("my var name" -> "MyVarName"). + * @note Operates on ASCII characters only; non-ASCII letters/digits are not classified or cased. + * @param String - The string to convert. + * @returns The PascalCased string. + */ + UFUNCTION(BlueprintPure, Category = "Directive Utilities|String") + static FString ToPascalCase(const FString& String); + + /** + * Converts a string to snake_case: every word lowercased, joined with underscores + * ("my var name" -> "my_var_name"). + * @note Operates on ASCII characters only; non-ASCII letters/digits are not classified or cased. + * @param String - The string to convert. + * @returns The snake_cased string. + */ + UFUNCTION(BlueprintPure, Category = "Directive Utilities|String") + static FString ToSnakeCase(const FString& String); + + /** + * Converts a string to kebab-case: every word lowercased, joined with hyphens + * ("my var name" -> "my-var-name"). + * @note Operates on ASCII characters only; non-ASCII letters/digits are not classified or cased. + * @param String - The string to convert. + * @returns The kebab-cased string. + */ + UFUNCTION(BlueprintPure, Category = "Directive Utilities|String") + static FString ToKebabCase(const FString& String); + + /** + * Returns a sorted copy of the provided string array. + * @param StringArray - The array of strings to sort. + * @returns A sorted copy of the provided string array. + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|String", meta = (DeprecatedFunction, DeprecationMessage = "Use the engine's Sort String Array node (UE 5.6+) instead.")) + static TArray GetSortedStringArray(TArray StringArray); + + /** + * Returns the Levenshtein (edit) distance between two strings: the minimum number of single-character + * insertions, deletions, or substitutions needed to turn one string into the other. + * @param A - The first string. + * @param B - The second string. + * @param bCaseSensitive - Whether the comparison is case-sensitive. Defaults to true. + * @returns The edit distance (0 means identical). + */ + UFUNCTION(BlueprintPure, Category = "Directive Utilities|String") + static int32 GetLevenshteinDistance(const FString& A, const FString& B, bool bCaseSensitive = true); + + /** + * Returns a normalized similarity score between two strings, from 0 (completely different) to 1 (identical), + * derived from the Levenshtein distance. Two empty strings are considered identical. + * @param A - The first string. + * @param B - The second string. + * @param bCaseSensitive - Whether the comparison is case-sensitive. Defaults to true. + * @returns The similarity in the [0, 1] range. + */ + UFUNCTION(BlueprintPure, Category = "Directive Utilities|String") + static float GetStringSimilarity(const FString& A, const FString& B, bool bCaseSensitive = true); + + /** + * Returns true if the source string contains any of the provided search terms. Empty terms are ignored. + * @param Source - The string to search. + * @param SearchTerms - The substrings to look for. + * @param bCaseSensitive - Whether the search is case-sensitive. Defaults to true. + * @returns True if at least one non-empty term is found. + */ + UFUNCTION(BlueprintPure, Category = "Directive Utilities|String") + static bool ContainsAny(const FString& Source, const TArray& SearchTerms, bool bCaseSensitive = true); + + /** + * Finds the earliest occurrence in the source string of any of the provided search terms. + * @param Source - The string to search. + * @param SearchTerms - The substrings to look for. Empty terms are ignored. + * @param bCaseSensitive - Whether the search is case-sensitive. Defaults to true. + * @param OutFoundIndex - [out] The index in Source of the earliest match, or INDEX_NONE if none. + * @param OutTermIndex - [out] The index into SearchTerms of the matched term, or INDEX_NONE if none. + * @returns True if any term was found. + */ + UFUNCTION(BlueprintPure, Category = "Directive Utilities|String") + static bool FindFirstOfAny(const FString& Source, const TArray& SearchTerms, bool bCaseSensitive, int32& OutFoundIndex, int32& OutTermIndex); + + /** + * Encodes a string to Base64. + * @param Source - The string to encode. + * @returns The Base64-encoded string. + */ + UFUNCTION(BlueprintPure, Category = "Directive Utilities|String") + static FString Base64Encode(const FString& Source); + + /** + * Decodes a Base64 string. + * @param Source - The Base64 string to decode. + * @param OutDecoded - [out] The decoded string, or empty on failure. + * @returns True if the input was valid Base64 and was decoded. + */ + UFUNCTION(BlueprintPure, Category = "Directive Utilities|String") + static bool Base64Decode(const FString& Source, FString& OutDecoded); + + /** + * Encodes a string as lowercase hex. The string is converted to UTF-8 bytes first. + * @param String - The string to encode. + * @returns The lowercase hex encoding of the string's UTF-8 bytes. + */ + UFUNCTION(BlueprintPure, Category = "Directive Utilities|String") + static FString HexEncode(const FString& String); + + /** + * Decodes a hex string (either case accepted) into the string its bytes spell in UTF-8. + * @param Hex - The hex string to decode. + * @param OutString - [out] The decoded string, or empty on failure. + * @returns True if the input was valid even-length hex and was decoded. + */ + UFUNCTION(BlueprintPure, Category = "Directive Utilities|String") + static bool HexDecode(const FString& Hex, FString& OutString); + + /** + * Encodes a byte array as lowercase hex. + * @param Bytes - The bytes to encode. + * @returns The lowercase hex encoding of the bytes. + */ + UFUNCTION(BlueprintPure, Category = "Directive Utilities|String") + static FString HexEncodeBytes(const TArray& Bytes); + + /** + * Decodes a hex string (either case accepted) into a byte array. + * @param Hex - The hex string to decode. + * @param OutBytes - [out] The decoded bytes, or empty on failure. + * @returns True if the input was valid even-length hex and was decoded. + */ + UFUNCTION(BlueprintPure, Category = "Directive Utilities|String") + static bool HexDecodeBytes(const FString& Hex, TArray& OutBytes); + + /** + * Returns the MD5 digest of the string as 32 lowercase hex characters. The string is + * converted to UTF-8 bytes first. + * @note For integrity checks and cache keys, not for security or password storage. + * @param String - The string to hash. + * @returns The MD5 digest as lowercase hex. + */ + UFUNCTION(BlueprintPure, Category = "Directive Utilities|String") + static FString Md5HashString(const FString& String); + + /** + * Returns the MD5 digest of a byte array as 32 lowercase hex characters. + * @note For integrity checks and cache keys, not for security or password storage. + * @param Bytes - The bytes to hash. + * @returns The MD5 digest as lowercase hex. + */ + UFUNCTION(BlueprintPure, Category = "Directive Utilities|String") + static FString Md5HashBytes(const TArray& Bytes); + + /** + * Returns the SHA-1 digest of the string as 40 lowercase hex characters. The string is + * converted to UTF-8 bytes first. + * @note For integrity checks and cache keys, not for security or password storage. + * @param String - The string to hash. + * @returns The SHA-1 digest as lowercase hex. + */ + UFUNCTION(BlueprintPure, Category = "Directive Utilities|String") + static FString Sha1HashString(const FString& String); + + /** + * Returns the SHA-1 digest of a byte array as 40 lowercase hex characters. + * @note For integrity checks and cache keys, not for security or password storage. + * @param Bytes - The bytes to hash. + * @returns The SHA-1 digest as lowercase hex. + */ + UFUNCTION(BlueprintPure, Category = "Directive Utilities|String") + static FString Sha1HashBytes(const TArray& Bytes); + + /** + * Returns the CRC32 checksum of the string (seed 0). The string is converted to + * UTF-8 bytes first. + * @note For integrity checks and cache keys, not for security or password storage. + * @param String - The string to checksum. + * @returns The CRC32 checksum. + */ + UFUNCTION(BlueprintPure, Category = "Directive Utilities|String") + static int32 Crc32String(const FString& String); + + /** + * Returns the CRC32 checksum of a byte array (seed 0). + * @note For integrity checks and cache keys, not for security or password storage. + * @param Bytes - The bytes to checksum. + * @returns The CRC32 checksum. + */ + UFUNCTION(BlueprintPure, Category = "Directive Utilities|String") + static int32 Crc32Bytes(const TArray& Bytes); + + /** + * Checks whether the string is safe to use as a bare file name: not empty, no path + * separators or relative segments, and no characters invalid in file names. + * @param String - The string to check. + * @returns True if the string is a valid bare file name. + */ + UFUNCTION(BlueprintPure, Category = "Directive Utilities|String") + static bool IsValidFileName(const FString& String); + + /** + * Returns the string with path separators and characters invalid in file names removed + * (or replaced when a replacement character is provided). May return an empty string. + * @param String - The string to sanitize. + * @param Replacement - Optional single-character replacement for stripped characters. + * @returns The sanitized file name. + */ + UFUNCTION(BlueprintPure, Category = "Directive Utilities|String") + static FString SanitizeFileName(const FString& String, const FString& Replacement = TEXT("")); + + /** + * Finds the entry in Candidates most similar to Input (by GetStringSimilarity). + * Ties resolve to the earliest index. Cost grows with array size and string + * lengths (Levenshtein per candidate). Avoid very large arrays per frame. + * @note bCaseSensitive defaults to false (matching user input), unlike the pairwise + * comparison functions where it defaults to true. + * @param Input - The string to match. + * @param Candidates - The candidate strings. + * @param OutSimilarity - [out] The winning similarity in [0, 1], 0 when empty. + * @param bCaseSensitive - Whether comparison is case-sensitive. Defaults to false. + * @returns The index of the best match, or -1 if Candidates is empty. + */ + UFUNCTION(BlueprintPure, Category = "Directive Utilities|String") + static int32 FindBestStringMatch(const FString& Input, const TArray& Candidates, float& OutSimilarity, bool bCaseSensitive = false); +}; diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilTextFunctionLibrary.h b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilTextFunctionLibrary.h new file mode 100644 index 00000000..f6234129 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilTextFunctionLibrary.h @@ -0,0 +1,26 @@ +// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License. + +#pragma once + +#include "CoreMinimal.h" +#include "Kismet/BlueprintFunctionLibrary.h" +#include "DirectiveUtilTextFunctionLibrary.generated.h" + +/** + * DirectiveUtilTextFunctionLibrary + * A collection of helpful text utility functions that improve the usability of text in Blueprints. + */ +UCLASS() +class DIRECTIVEUTILITIESRUNTIME_API UDirectiveUtilTextFunctionLibrary : public UBlueprintFunctionLibrary +{ + GENERATED_BODY() + +public: + + /** + * Returns true if the provided text is not empty. + * @param Text - The text to check. + */ + UFUNCTION(BlueprintPure, Category = "Directive Utilities|Text" ) + static bool IsNotEmpty(const FText& Text); +}; diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Tasks/DirectiveUtilTask_AsyncLoadAsset.h b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Tasks/DirectiveUtilTask_AsyncLoadAsset.h new file mode 100644 index 00000000..50690b3d --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Tasks/DirectiveUtilTask_AsyncLoadAsset.h @@ -0,0 +1,181 @@ +// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License. + +#pragma once + +#include "CoreMinimal.h" +#include "Kismet/BlueprintAsyncActionBase.h" +#include "UObject/SoftObjectPtr.h" +#include "DirectiveUtilTask_AsyncLoadAsset.generated.h" + +struct FStreamableHandle; + +DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnAsyncLoadAssetCompleted, UObject*, LoadedAsset); +DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnAsyncLoadClassCompleted, UClass*, LoadedClass); +DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnAsyncLoadAssetsCompleted, const TArray&, LoadedAssets); +DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnAsyncLoadAssetsProgress, int32, LoadedCount, int32, TotalCount); + +/** + * DirectiveUtilTask_AsyncLoadAsset + * Asynchronously loads a soft object reference and broadcasts the loaded asset, with a cancel option. + */ +UCLASS(BlueprintType, meta=(ExposedAsyncProxy = AsyncTask, DisplayName="Async Load Asset")) +class DIRECTIVEUTILITIESRUNTIME_API UDirectiveUtilTask_AsyncLoadAsset : public UBlueprintAsyncActionBase +{ + GENERATED_BODY() + +public: + + /** + * Asynchronously loads the asset referenced by a soft object pointer. + * The Completed delegate is called with the loaded asset on success; the Failed delegate is called with null on failure. A manual Cancel() does not broadcast Failed. + * + * @param WorldContextObject The world context object. + * @param Asset The soft object reference to load. + */ + UFUNCTION( + BlueprintCallable, + meta=( + BlueprintInternalUseOnly = "true", + Category = "Directive Utilities|AssetManagement", + WorldContext = "WorldContextObject", + DisplayName = "Async Load Asset" + )) + static UDirectiveUtilTask_AsyncLoadAsset* AsyncLoadAsset(UObject* WorldContextObject, const TSoftObjectPtr Asset); + + /** + * Cancels the in-progress load. The Failed delegate is not broadcast for a manual cancel. + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|AssetManagement") + void Cancel(); + + virtual void Activate() override; + + // Called when the asset has finished loading. The loaded asset is valid. + UPROPERTY(BlueprintAssignable) + FOnAsyncLoadAssetCompleted Completed; + + // Called when the load failed. The loaded asset is null. + UPROPERTY(BlueprintAssignable) + FOnAsyncLoadAssetCompleted Failed; + +protected: + + TSoftObjectPtr SoftAsset; + TSharedPtr StreamableHandle; + + void OnLoaded(); +}; + +/** + * DirectiveUtilTask_AsyncLoadClass + * Asynchronously loads a soft class reference and broadcasts the loaded class, with a cancel option. + */ +UCLASS(BlueprintType, meta=(ExposedAsyncProxy = AsyncTask, DisplayName="Async Load Class")) +class DIRECTIVEUTILITIESRUNTIME_API UDirectiveUtilTask_AsyncLoadClass : public UBlueprintAsyncActionBase +{ + GENERATED_BODY() + +public: + + /** + * Asynchronously loads the class referenced by a soft class pointer. + * The Completed delegate is called with the loaded class on success; the Failed delegate is called with null on failure. A manual Cancel() does not broadcast Failed. + * + * @param WorldContextObject The world context object. + * @param AssetClass The soft class reference to load. + */ + UFUNCTION( + BlueprintCallable, + meta=( + BlueprintInternalUseOnly = "true", + Category = "Directive Utilities|AssetManagement", + WorldContext = "WorldContextObject", + DisplayName = "Async Load Class" + )) + static UDirectiveUtilTask_AsyncLoadClass* AsyncLoadClass(UObject* WorldContextObject, const TSoftClassPtr AssetClass); + + /** + * Cancels the in-progress load. The Failed delegate is not broadcast for a manual cancel. + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|AssetManagement") + void Cancel(); + + virtual void Activate() override; + + // Called when the class has finished loading. The loaded class is valid. + UPROPERTY(BlueprintAssignable) + FOnAsyncLoadClassCompleted Completed; + + // Called when the load failed. The loaded class is null. + UPROPERTY(BlueprintAssignable) + FOnAsyncLoadClassCompleted Failed; + +protected: + + TSoftClassPtr SoftClass; + TSharedPtr StreamableHandle; + + void OnLoaded(); +}; + +/** + * DirectiveUtilTask_AsyncLoadAssets + * Asynchronously loads a batch of soft object references in a single request and broadcasts the + * loaded assets, with progress updates and a cancel option. + */ +UCLASS(BlueprintType, meta=(ExposedAsyncProxy = AsyncTask, DisplayName="Async Load Assets")) +class DIRECTIVEUTILITIESRUNTIME_API UDirectiveUtilTask_AsyncLoadAssets : public UBlueprintAsyncActionBase +{ + GENERATED_BODY() + +public: + + /** + * Asynchronously loads the assets referenced by an array of soft object pointers in a single request. + * The Completed delegate is called exactly once with the loaded assets in input order; entries that were + * unset or failed to resolve are null. An empty input array completes immediately with an empty array. + * A manual Cancel() does not broadcast Completed. + * + * @param WorldContextObject The world context object. + * @param Assets The soft object references to load. + */ + UFUNCTION( + BlueprintCallable, + meta=( + BlueprintInternalUseOnly = "true", + Category = "Directive Utilities|AssetManagement", + WorldContext = "WorldContextObject", + DisplayName = "Async Load Assets" + )) + static UDirectiveUtilTask_AsyncLoadAssets* AsyncLoadAssets(UObject* WorldContextObject, const TArray>& Assets); + + /** + * Cancels the in-progress load. The Completed delegate is not broadcast for a manual cancel. + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|AssetManagement") + void Cancel(); + + virtual void Activate() override; + + // Called exactly once when the batch has finished loading. The assets are in input order with null + // entries for references that were unset or failed to resolve, and are only guaranteed alive during + // this broadcast. + UPROPERTY(BlueprintAssignable) + FOnAsyncLoadAssetsCompleted Completed; + + // Called as assets arrive, with the number loaded so far and the total requested. Not called when + // the request finishes before the first update (e.g. all assets were already in memory). + UPROPERTY(BlueprintAssignable) + FOnAsyncLoadAssetsProgress Progress; + +protected: + + TArray> SoftAssets; + TSharedPtr StreamableHandle; + + /* Whether the task has already completed, guarding against a second broadcast. */ + bool bHasCompleted = false; + + void OnLoaded(); + void OnUpdate(TSharedRef Handle); +}; diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Tasks/DirectiveUtilTask_AsyncTrace.h b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Tasks/DirectiveUtilTask_AsyncTrace.h new file mode 100644 index 00000000..080b5983 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Tasks/DirectiveUtilTask_AsyncTrace.h @@ -0,0 +1,136 @@ +// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License. + +#pragma once + +#include "CoreMinimal.h" +#include "Kismet/BlueprintAsyncActionBase.h" +#include "Engine/EngineTypes.h" +#include "Engine/HitResult.h" +#include "DirectiveUtilTask_AsyncTrace.generated.h" + +struct FTraceHandle; +struct FTraceDatum; + +DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnAsyncTraceCompleted, const TArray&, Hits); + +/** The collision shape used by an async trace. */ +enum class EDirectiveUtilTraceShape : uint8 +{ + Line, + Sphere, + Box, + Capsule, +}; + +/** + * DirectiveUtilTask_AsyncTrace + * Queues a collision trace and broadcasts the hit results on the next tick. + * The trace cannot be cancelled. + */ +UCLASS(BlueprintType, meta=(ExposedAsyncProxy = AsyncTask, DisplayName="Async Trace")) +class DIRECTIVEUTILITIESRUNTIME_API UDirectiveUtilTask_AsyncTrace : public UBlueprintAsyncActionBase +{ + GENERATED_BODY() + +public: + + /** + * Performs an asynchronous line trace against the given trace channel. + * @param WorldContextObject The world context object. + * @param Start The start of the trace. + * @param End The end of the trace. + * @param TraceChannel The trace channel to test against. + * @param bMultiTrace If true, returns all hits up to and including the first blocking hit; otherwise returns only the first blocking hit. + */ + UFUNCTION( + BlueprintCallable, + meta=( + BlueprintInternalUseOnly = "true", + Category = "Directive Utilities|Collision", + WorldContext = "WorldContextObject", + DisplayName = "Async Line Trace By Channel" + )) + static UDirectiveUtilTask_AsyncTrace* AsyncLineTraceByChannel(UObject* WorldContextObject, const FVector Start, const FVector End, const ETraceTypeQuery TraceChannel, const bool bMultiTrace = false); + + /** + * Performs an asynchronous sphere sweep against the given trace channel. + * @param WorldContextObject The world context object. + * @param Start The start of the sweep. + * @param End The end of the sweep. + * @param Radius The radius of the sphere. + * @param TraceChannel The trace channel to test against. + * @param bMultiTrace If true, returns all hits up to and including the first blocking hit; otherwise returns only the first blocking hit. + */ + UFUNCTION( + BlueprintCallable, + meta=( + BlueprintInternalUseOnly = "true", + Category = "Directive Utilities|Collision", + WorldContext = "WorldContextObject", + DisplayName = "Async Sphere Trace By Channel" + )) + static UDirectiveUtilTask_AsyncTrace* AsyncSphereTraceByChannel(UObject* WorldContextObject, const FVector Start, const FVector End, const float Radius, const ETraceTypeQuery TraceChannel, const bool bMultiTrace = false); + + /** + * Performs an asynchronous box sweep against the given trace channel. + * @param WorldContextObject The world context object. + * @param Start The start of the sweep. + * @param End The end of the sweep. + * @param HalfSize The half-extents of the box. + * @param Orientation The orientation of the box. + * @param TraceChannel The trace channel to test against. + * @param bMultiTrace If true, returns all hits up to and including the first blocking hit; otherwise returns only the first blocking hit. + */ + UFUNCTION( + BlueprintCallable, + meta=( + BlueprintInternalUseOnly = "true", + Category = "Directive Utilities|Collision", + WorldContext = "WorldContextObject", + DisplayName = "Async Box Trace By Channel" + )) + static UDirectiveUtilTask_AsyncTrace* AsyncBoxTraceByChannel(UObject* WorldContextObject, const FVector Start, const FVector End, const FVector HalfSize, const FRotator Orientation, const ETraceTypeQuery TraceChannel, const bool bMultiTrace = false); + + /** + * Performs an asynchronous capsule sweep against the given trace channel. + * @param WorldContextObject The world context object. + * @param Start The start of the sweep. + * @param End The end of the sweep. + * @param Radius The radius of the capsule. + * @param HalfHeight The half-height of the capsule (including the radius). + * @param TraceChannel The trace channel to test against. + * @param bMultiTrace If true, returns all hits up to and including the first blocking hit; otherwise returns only the first blocking hit. + */ + UFUNCTION( + BlueprintCallable, + meta=( + BlueprintInternalUseOnly = "true", + Category = "Directive Utilities|Collision", + WorldContext = "WorldContextObject", + DisplayName = "Async Capsule Trace By Channel" + )) + static UDirectiveUtilTask_AsyncTrace* AsyncCapsuleTraceByChannel(UObject* WorldContextObject, const FVector Start, const FVector End, const float Radius, const float HalfHeight, const ETraceTypeQuery TraceChannel, const bool bMultiTrace = false); + + virtual void Activate() override; + + // Called when the trace has completed. Empty if nothing was hit. + UPROPERTY(BlueprintAssignable) + FOnAsyncTraceCompleted Completed; + +protected: + + UPROPERTY() + TObjectPtr WorldContextObject; + + FVector Start = FVector::ZeroVector; + FVector End = FVector::ZeroVector; + ETraceTypeQuery TraceChannel = ETraceTypeQuery::TraceTypeQuery1; + bool bMultiTrace = false; + EDirectiveUtilTraceShape Shape = EDirectiveUtilTraceShape::Line; + float Radius = 0.0f; + float HalfHeight = 0.0f; + FVector HalfSize = FVector::ZeroVector; + FQuat Orientation = FQuat::Identity; + + void OnTraceComplete(const FTraceHandle& Handle, FTraceDatum& Datum); +}; diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Tasks/DirectiveUtilTask_Delay.h b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Tasks/DirectiveUtilTask_Delay.h new file mode 100644 index 00000000..a8167372 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Tasks/DirectiveUtilTask_Delay.h @@ -0,0 +1,61 @@ +// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License. + +#pragma once + +#include "CoreMinimal.h" +#include "Kismet/BlueprintAsyncActionBase.h" +#include "Engine/TimerHandle.h" +#include "DirectiveUtilTask_Delay.generated.h" + +DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnDelayCompleted); + +/** + * DirectiveUtilTask_Delay + * A cancellable delay that can be ended early by calling EndTask. + */ +UCLASS(BlueprintType, meta=(ExposedAsyncProxy = AsyncTask, DisplayName="Cancellable Delay")) +class DIRECTIVEUTILITIESRUNTIME_API UDirectiveUtilTask_Delay : public UBlueprintAsyncActionBase +{ + GENERATED_BODY() + +public: + + /** + * Starts a cancellable delay. + * When the delay has completed, the Completed delegate is called. + * Call EndTask to cancel the delay before it completes. + * + * @param WorldContextObject The world context object. + * @param Duration The duration of the delay in seconds. + */ + UFUNCTION( + BlueprintCallable, + meta=( + BlueprintInternalUseOnly = "true", + Category = "Directive Utilities|FlowControl", + WorldContext = "WorldContextObject", + DisplayName = "Cancellable Delay" + )) + static UDirectiveUtilTask_Delay* CancellableDelay(UObject* WorldContextObject, float Duration); + + /** + * Ends the delay early. + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|FlowControl") + void EndTask(); + virtual void Activate() override; + + // The delegate called when the delay has completed. + UPROPERTY(BlueprintAssignable) + FOnDelayCompleted Completed; + +protected: + + UPROPERTY() + TObjectPtr WorldContextObject; + + float Duration = 0.0f; + FTimerHandle TimerHandle; + + void OnDelayComplete(); +}; diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Tasks/DirectiveUtilTask_MoveToLocation.h b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Tasks/DirectiveUtilTask_MoveToLocation.h new file mode 100644 index 00000000..d5756b94 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Tasks/DirectiveUtilTask_MoveToLocation.h @@ -0,0 +1,181 @@ +// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License. + +#pragma once + +#include "CoreMinimal.h" +#include "Kismet/BlueprintAsyncActionBase.h" +#include "GameFramework/Controller.h" +#include "DirectiveUtilTask_MoveToLocation.generated.h" + +DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnAsyncMoveToLocation, bool, bSuccess); +DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnAsyncMoveToActor, bool, bSuccess); + +/** + * DirectiveUtilTask_MoveToLocation + * Asynchronously moves an actor to a location. + */ +UCLASS(BlueprintType, meta=(ExposedAsyncProxy = AsyncTask, DisplayName="Async Move To Location")) +class DIRECTIVEUTILITIESRUNTIME_API UDirectiveUtilTask_MoveToLocation : public UBlueprintAsyncActionBase +{ + GENERATED_BODY() + +public: + + /** + * Moves the actor to the specified location. + * When the movement has succeeded or failed, the Completed delegate is called exactly once with success/failure. + * bSuccess is true only when the pawn ends within AcceptanceRadius of Destination; the task also ends + * (with the same distance test) when path-following stops for any reason. + * + * If the controller or pawn is destroyed while moving, the task will automatically end. + * If bCheckStuckMovement is enabled and the controller gets stuck while moving, the task will automatically end. + * + * @param WorldContextObject The world context object. + * @param Controller The controller to move. + * @param Destination The vector location to move to. + * @param AcceptanceRadius The radius around the destination location that is considered acceptable. Be sure to set this to a reasonable value as the controller may never reach the exact destination. + * @param bCheckStuckMovement Check if the controller gets stuck while moving. + * @param StuckThreshold The distance threshold to consider the controller stuck. + * @param bDebugLineTrace Display a line trace to the destination location for a short duration. + */ + UFUNCTION( + BlueprintCallable, + meta=( + BlueprintInternalUseOnly = "true", + Category = "Directive Utilities|Navigation", + WorldContext = "WorldContextObject", + DisplayName = "Async Move To Location", + AdvancedDisplay=6 + )) + static UDirectiveUtilTask_MoveToLocation* MoveToLocation( + UObject* WorldContextObject, + AController* Controller, + FVector Destination, + float AcceptanceRadius = 100.0f, + bool bCheckStuckMovement = true, + float StuckThreshold = 1.0f, + bool bDebugLineTrace = false); + + /** + * Ends the async action. + * This must be called manually when the task is no longer necessary. + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Navigation") + void EndTask(); + virtual void Activate() override; + + // The delegate called when the movement has completed regardless of success. Fires exactly once. + UPROPERTY(BlueprintAssignable) + FOnAsyncMoveToLocation Completed; + +protected: + + UPROPERTY() + AController* Controller; + + FVector Destination; + FVector StartLocation; + FVector CurrentLocation; + FVector LastCheckedLocation; + float AcceptanceRadius = 10.0f; + bool bCheckStuckMovement = true; + float StuckThreshold = 1.0f; + bool bDebugLineTrace; + + FTimerHandle TimerHandle; + + FTimerHandle StuckTimerHandle; + + bool bHasCompleted = false; + + void CheckMoveToLocation(); + + void CheckStuckMovement(); + + virtual void ExecuteCompleted(bool bSuccess); +}; + +/** + * DirectiveUtilTask_MoveToActor + * Asynchronously moves an actor to another actor. + */ +UCLASS(BlueprintType, meta=(ExposedAsyncProxy = AsyncTask, DisplayName="Async Move To Actor")) +class DIRECTIVEUTILITIESRUNTIME_API UDirectiveUtilTask_MoveToActor : public UBlueprintAsyncActionBase +{ + GENERATED_BODY() + +public: + + /** + * Moves the controller's pawn to the goal actor. + * When the movement has succeeded or failed, the Completed delegate is called exactly once with success/failure. + * bSuccess is true only when the pawn ends within AcceptanceRadius of the goal actor; the task also ends + * (with the same distance test) when path-following stops for any reason. The goal's location is re-read + * every poll, so a moving goal is tracked. + * + * If the controller, pawn, or goal actor is destroyed while moving, the task will automatically end. + * If bCheckStuckMovement is enabled and the controller gets stuck while moving, the task will automatically end. + * + * @param WorldContextObject The world context object. + * @param Controller The controller to move. + * @param Goal The actor to move to. + * @param AcceptanceRadius The radius around the goal actor that is considered acceptable. Be sure to set this to a reasonable value as the controller may never reach the goal's exact location. + * @param bCheckStuckMovement Check if the controller gets stuck while moving. + * @param StuckThreshold The distance threshold to consider the controller stuck. + */ + UFUNCTION( + BlueprintCallable, + meta=( + BlueprintInternalUseOnly = "true", + Category = "Directive Utilities|Navigation", + WorldContext = "WorldContextObject", + DisplayName = "Async Move To Actor" + )) + static UDirectiveUtilTask_MoveToActor* MoveToActor( + UObject* WorldContextObject, + AController* Controller, + AActor* Goal, + float AcceptanceRadius = 100.0f, + bool bCheckStuckMovement = true, + float StuckThreshold = 1.0f); + + /** + * Ends the async action. + * This must be called manually when the task is no longer necessary. + */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Navigation") + void EndTask(); + virtual void Activate() override; + + // The delegate called when the movement has completed regardless of success. Fires exactly once. + UPROPERTY(BlueprintAssignable) + FOnAsyncMoveToActor Completed; + +protected: + + UPROPERTY() + AController* Controller; + + // The cached goal actor; GC nulls it if the actor is destroyed. + UPROPERTY() + TObjectPtr Goal; + + FVector StartLocation; + FVector CurrentLocation; + FVector LastCheckedLocation; + float AcceptanceRadius = 10.0f; + bool bCheckStuckMovement = true; + float StuckThreshold = 1.0f; + + FTimerHandle TimerHandle; + + FTimerHandle StuckTimerHandle; + + bool bHasCompleted = false; + + void CheckMoveToActor(); + + void CheckStuckMovement(); + + virtual void ExecuteCompleted(bool bSuccess); +}; diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Types/DirectiveUtilInputTypes.h b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Types/DirectiveUtilInputTypes.h new file mode 100644 index 00000000..dc901a71 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Types/DirectiveUtilInputTypes.h @@ -0,0 +1,26 @@ +#pragma once + +#include "CoreMinimal.h" +#include "DirectiveUtilInputTypes.generated.h" + +class UInputMappingContext; + +/** An input mapping context and its application priority. */ +USTRUCT(BlueprintType) +struct FDirectiveUtilEnhancedInputContextData +{ + GENERATED_BODY() + + /** The input context to be used. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Input") + TSoftObjectPtr InputContext; + + /** The priority of the input context. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Input") + int32 Priority; + + FDirectiveUtilEnhancedInputContextData() + : Priority(0) + { + } +}; diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Types/DirectiveUtilMathTypes.h b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Types/DirectiveUtilMathTypes.h new file mode 100644 index 00000000..664a5675 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Types/DirectiveUtilMathTypes.h @@ -0,0 +1,23 @@ +// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License. + +#pragma once + +#include "CoreMinimal.h" +#include "DirectiveUtilMathTypes.generated.h" + +/** + * Easing curves not provided by the engine's built-in Ease node (EEasingFunc): the classic Penner Back, Elastic and Bounce curves. + */ +UENUM(BlueprintType) +enum class EDirectiveUtilEaseType : uint8 +{ + BackIn UMETA(DisplayName = "Back In", Tooltip="Overshoots slightly at the start before easing in."), + BackOut UMETA(DisplayName = "Back Out", Tooltip="Overshoots slightly past the end before settling."), + BackInOut UMETA(DisplayName = "Back In Out", Tooltip="Overshoots at both the start and the end."), + ElasticIn UMETA(DisplayName = "Elastic In", Tooltip="Oscillates with increasing amplitude before easing in."), + ElasticOut UMETA(DisplayName = "Elastic Out", Tooltip="Oscillates with decreasing amplitude after the end."), + ElasticInOut UMETA(DisplayName = "Elastic In Out", Tooltip="Oscillates at both the start and the end."), + BounceIn UMETA(DisplayName = "Bounce In", Tooltip="Bounces with increasing energy before easing in."), + BounceOut UMETA(DisplayName = "Bounce Out", Tooltip="Bounces with decreasing energy after the end."), + BounceInOut UMETA(DisplayName = "Bounce In Out", Tooltip="Bounces at both the start and the end."), +}; diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Types/DirectiveUtilTypes.h b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Types/DirectiveUtilTypes.h new file mode 100644 index 00000000..b17d71f8 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Types/DirectiveUtilTypes.h @@ -0,0 +1,14 @@ +#pragma once + +#include "CoreMinimal.h" +#include "DirectiveUtilTypes.generated.h" + +/** + * Provides a list of success types. + */ +UENUM(BlueprintType) +enum class EDirectiveUtilSuccessStatus : uint8 +{ + Success, + Failure, +}; \ No newline at end of file diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/DirectiveUtilitiesTests.Build.cs b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/DirectiveUtilitiesTests.Build.cs new file mode 100644 index 00000000..162ec6c0 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/DirectiveUtilitiesTests.Build.cs @@ -0,0 +1,35 @@ +// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License. + +using UnrealBuildTool; + +public class DirectiveUtilitiesTests : ModuleRules +{ + public DirectiveUtilitiesTests(ReadOnlyTargetRules Target) : base(Target) + { + PCHUsage = ModuleRules.PCHUsageMode.UseExplicitOrSharedPCHs; + + PublicDependencyModuleNames.AddRange( + new string[] + { + "Core", + } + ); + + PrivateDependencyModuleNames.AddRange( + new string[] + { + "CoreUObject", + "Engine", + "DirectiveUtilitiesRuntime", + "AutomationTest", + "EnhancedInput", + "GameplayTags" + } + ); + + if (Target.bBuildEditor) + { + PrivateDependencyModuleNames.Add("DirectiveUtilitiesEditor"); + } + } +} \ No newline at end of file diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/DirectiveUtilitiesTests.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/DirectiveUtilitiesTests.cpp new file mode 100644 index 00000000..a8e7688c --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/DirectiveUtilitiesTests.cpp @@ -0,0 +1,13 @@ +// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License. + +#include "DirectiveUtilitiesTests.h" + +void FDirectiveUtilitiesTestsModule::StartupModule() +{ +} + +void FDirectiveUtilitiesTestsModule::ShutdownModule() +{ +} + +IMPLEMENT_MODULE(FDirectiveUtilitiesTestsModule, DirectiveUtilitiesTests) diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilArrayFunctionLibraryTest.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilArrayFunctionLibraryTest.cpp new file mode 100644 index 00000000..4593907e --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilArrayFunctionLibraryTest.cpp @@ -0,0 +1,211 @@ +#include "Libraries/DirectiveUtilArrayFunctionLibrary.h" +#include "Tests/DirectiveUtilTestObject.h" +#include "Misc/AutomationTest.h" + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilArrayFunctionLibraryTest, "DirectiveUtilities.ArrayFunctionLibraryTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter) + +bool FDirectiveUtilArrayFunctionLibraryTest::RunTest(const FString& Parameters) +{ + UDirectiveUtilTestObject* TestObject = NewObject(); + + TestObject->TestArray = {1, 2, 3, 4, 5}; + + FArrayProperty* ArrayProperty = FindFProperty(UDirectiveUtilTestObject::StaticClass(), GET_MEMBER_NAME_CHECKED(UDirectiveUtilTestObject, TestArray)); + + const int32 NextIndex = UDirectiveUtilArrayFunctionLibrary::GenericArray_NextIndex(&TestObject->TestArray, ArrayProperty, 2, false); + const int32 PreviousIndex = UDirectiveUtilArrayFunctionLibrary::GenericArray_PreviousIndex(&TestObject->TestArray, ArrayProperty, 2, false); + + TestEqual("Array_NextIndex should return the next index in the array", NextIndex, 3); + TestEqual("Array_PreviousIndex should return the previous index in the array", PreviousIndex, 1); + + const int32 NextIndexLooped = UDirectiveUtilArrayFunctionLibrary::GenericArray_NextIndex(&TestObject->TestArray, ArrayProperty, 4, true); + const int32 PreviousIndexLooped = UDirectiveUtilArrayFunctionLibrary::GenericArray_PreviousIndex(&TestObject->TestArray, ArrayProperty, 0, true); + + TestEqual("Array_NextIndex should return the first index when looping", NextIndexLooped, 0); + TestEqual("Array_PreviousIndex should return the last index when looping", PreviousIndexLooped, 4); + + const int32 NextIndexNonLooped = UDirectiveUtilArrayFunctionLibrary::GenericArray_NextIndex(&TestObject->TestArray, ArrayProperty, 4, false); + const int32 PreviousIndexNonLooped = UDirectiveUtilArrayFunctionLibrary::GenericArray_PreviousIndex(&TestObject->TestArray, ArrayProperty, 0, false); + + TestEqual("Array_NextIndex should return the last index when not looping", NextIndexNonLooped, 4); + TestEqual("Array_PreviousIndex should return the first index when not looping", PreviousIndexNonLooped, 0); + + const int32 NextIndexOutOfBounds = UDirectiveUtilArrayFunctionLibrary::GenericArray_NextIndex(&TestObject->TestArray, ArrayProperty, 5, false); + const int32 PreviousIndexOutOfBounds = UDirectiveUtilArrayFunctionLibrary::GenericArray_PreviousIndex(&TestObject->TestArray, ArrayProperty, -1, false); + + TestEqual("Array_NextIndex should return the last index when out of bounds", NextIndexOutOfBounds, 4); + TestEqual("Array_PreviousIndex should return the first index when out of bounds", PreviousIndexOutOfBounds, 0); + + TestEqual("Array_NextIndex should clamp a negative input index to 0 (no loop)", + UDirectiveUtilArrayFunctionLibrary::GenericArray_NextIndex(&TestObject->TestArray, ArrayProperty, -2, false), 0); + TestEqual("Array_NextIndex should clamp a negative input index to 0 (loop)", + UDirectiveUtilArrayFunctionLibrary::GenericArray_NextIndex(&TestObject->TestArray, ArrayProperty, -2, true), 0); + TestEqual("Array_NextIndex should wrap a large input index to 0 when looping", + UDirectiveUtilArrayFunctionLibrary::GenericArray_NextIndex(&TestObject->TestArray, ArrayProperty, 10, true), 0); + TestEqual("Array_NextIndex should clamp a large input index to the last index when not looping", + UDirectiveUtilArrayFunctionLibrary::GenericArray_NextIndex(&TestObject->TestArray, ArrayProperty, 10, false), 4); + TestEqual("Array_PreviousIndex should clamp a large input index to the last index (no loop)", + UDirectiveUtilArrayFunctionLibrary::GenericArray_PreviousIndex(&TestObject->TestArray, ArrayProperty, 10, false), 4); + TestEqual("Array_PreviousIndex should clamp a large input index to the last index (loop)", + UDirectiveUtilArrayFunctionLibrary::GenericArray_PreviousIndex(&TestObject->TestArray, ArrayProperty, 10, true), 4); + TestEqual("Array_PreviousIndex should wrap a negative input index to the last index when looping", + UDirectiveUtilArrayFunctionLibrary::GenericArray_PreviousIndex(&TestObject->TestArray, ArrayProperty, -1, true), 4); + + TestObject->TestArray = {1, 2, 3, 4, 5}; + + int32 FirstItem = -1; + const bool bGotFirst = UDirectiveUtilArrayFunctionLibrary::GenericArray_GetFirstItem(&TestObject->TestArray, ArrayProperty, &FirstItem); + TestTrue("GetFirstItem should succeed on a non-empty array", bGotFirst); + TestEqual("GetFirstItem should return the first element", FirstItem, 1); + + int32 LastItem = -1; + const bool bGotLast = UDirectiveUtilArrayFunctionLibrary::GenericArray_GetLastItem(&TestObject->TestArray, ArrayProperty, &LastItem); + TestTrue("GetLastItem should succeed on a non-empty array", bGotLast); + TestEqual("GetLastItem should return the last element", LastItem, 5); + + int32 IndexItem = -1; + const bool bGotIndex = UDirectiveUtilArrayFunctionLibrary::GenericArray_GetItemAtIndex(&TestObject->TestArray, ArrayProperty, 2, &IndexItem); + TestTrue("GetItemAtIndex should succeed for a valid index", bGotIndex); + TestEqual("GetItemAtIndex should return the element at the index", IndexItem, 3); + + int32 OutOfRangeItem = 777; + const bool bGotOutOfRange = UDirectiveUtilArrayFunctionLibrary::GenericArray_GetItemAtIndex(&TestObject->TestArray, ArrayProperty, 10, &OutOfRangeItem); + TestFalse("GetItemAtIndex should fail for an out-of-range index", bGotOutOfRange); + TestEqual("GetItemAtIndex should reset the output to default on failure", OutOfRangeItem, 0); + + int32 RandomItem = -1; + int32 RandomIndex = -1; + const bool bGotRandom = UDirectiveUtilArrayFunctionLibrary::GenericArray_GetRandomItem(&TestObject->TestArray, ArrayProperty, &RandomItem, &RandomIndex); + TestTrue("GetRandomItem should succeed on a non-empty array", bGotRandom); + TestTrue("GetRandomItem should return a valid index", TestObject->TestArray.IsValidIndex(RandomIndex)); + if (TestObject->TestArray.IsValidIndex(RandomIndex)) + { + TestEqual("GetRandomItem item should match the element at the returned index", RandomItem, TestObject->TestArray[RandomIndex]); + } + + TestObject->TestArray.Empty(); + + int32 EmptyItem = 999; + TestFalse("GetFirstItem should fail on an empty array", UDirectiveUtilArrayFunctionLibrary::GenericArray_GetFirstItem(&TestObject->TestArray, ArrayProperty, &EmptyItem)); + TestEqual("GetFirstItem should reset the output to default on an empty array", EmptyItem, 0); + + EmptyItem = 999; + TestFalse("GetLastItem should fail on an empty array", UDirectiveUtilArrayFunctionLibrary::GenericArray_GetLastItem(&TestObject->TestArray, ArrayProperty, &EmptyItem)); + TestEqual("GetLastItem should reset the output to default on an empty array", EmptyItem, 0); + + int32 EmptyRandomItem = 999; + int32 EmptyRandomIndex = 5; + TestFalse("GetRandomItem should fail on an empty array", UDirectiveUtilArrayFunctionLibrary::GenericArray_GetRandomItem(&TestObject->TestArray, ArrayProperty, &EmptyRandomItem, &EmptyRandomIndex)); + TestEqual("GetRandomItem should return INDEX_NONE on an empty array", EmptyRandomIndex, static_cast(INDEX_NONE)); + TestEqual("GetRandomItem should reset the output to default on an empty array", EmptyRandomItem, 0); + + TestObject->TestArray = {1, 2, 3}; + int32 PoppedItem = -1; + const bool bPopped = UDirectiveUtilArrayFunctionLibrary::GenericArray_Pop(&TestObject->TestArray, ArrayProperty, &PoppedItem); + TestTrue("Pop should succeed on a non-empty array", bPopped); + TestEqual("Pop should return the last element", PoppedItem, 3); + TestEqual("Pop should shrink the array by one", TestObject->TestArray.Num(), 2); + TestEqual("Pop should leave the new last element intact", TestObject->TestArray.Last(), 2); + + TestObject->TestArray = {1, 2, 3}; + int32 PoppedFirst = -1; + const bool bPoppedFirst = UDirectiveUtilArrayFunctionLibrary::GenericArray_PopFirst(&TestObject->TestArray, ArrayProperty, &PoppedFirst); + TestTrue("PopFirst should succeed on a non-empty array", bPoppedFirst); + TestEqual("PopFirst should return the first element", PoppedFirst, 1); + TestEqual("PopFirst should shrink the array by one", TestObject->TestArray.Num(), 2); + TestEqual("PopFirst should shift the remaining elements down", TestObject->TestArray[0], 2); + + TestObject->TestArray.Empty(); + int32 PoppedEmpty = 888; + TestFalse("Pop should fail on an empty array", UDirectiveUtilArrayFunctionLibrary::GenericArray_Pop(&TestObject->TestArray, ArrayProperty, &PoppedEmpty)); + TestEqual("Pop should reset the output to default on an empty array", PoppedEmpty, 0); + TestFalse("PopFirst should fail on an empty array", UDirectiveUtilArrayFunctionLibrary::GenericArray_PopFirst(&TestObject->TestArray, ArrayProperty, &PoppedEmpty)); + + TestObject->TestArray = {10, 20, 30, 40}; + const bool bRemoved = UDirectiveUtilArrayFunctionLibrary::GenericArray_RemoveAtSwap(&TestObject->TestArray, ArrayProperty, 1); + TestTrue("RemoveAtSwap should succeed for a valid index", bRemoved); + TestEqual("RemoveAtSwap should shrink the array by one", TestObject->TestArray.Num(), 3); + TestFalse("RemoveAtSwap should have removed the target element", TestObject->TestArray.Contains(20)); + TestEqual("RemoveAtSwap should move the previously-last element into the removed slot", TestObject->TestArray[1], 40); + + const bool bRemovedOutOfRange = UDirectiveUtilArrayFunctionLibrary::GenericArray_RemoveAtSwap(&TestObject->TestArray, ArrayProperty, 99); + TestFalse("RemoveAtSwap should fail for an out-of-range index", bRemovedOutOfRange); + TestEqual("RemoveAtSwap should not change the array on failure", TestObject->TestArray.Num(), 3); + + TestObject->TestArray = {10, 20, 30}; + const bool bRemovedLast = UDirectiveUtilArrayFunctionLibrary::GenericArray_RemoveAtSwap(&TestObject->TestArray, ArrayProperty, 2); + TestTrue("RemoveAtSwap should succeed when removing the last element", bRemovedLast); + TestEqual("RemoveAtSwap on the last element should shrink the array", TestObject->TestArray.Num(), 2); + TestEqual("RemoveAtSwap on the last element should preserve the order of the rest", TestObject->TestArray[1], 20); + + TestObject->TestArray = {1, 2, 2, 3, 1, 4}; + UDirectiveUtilArrayFunctionLibrary::GenericArray_RemoveDuplicates(&TestObject->TestArray, ArrayProperty); + TestEqual("RemoveDuplicates should remove all duplicate entries", TestObject->TestArray.Num(), 4); + if (TestObject->TestArray.Num() == 4) + { + TestEqual("RemoveDuplicates should keep the first occurrence (index 0)", TestObject->TestArray[0], 1); + TestEqual("RemoveDuplicates should keep the first occurrence (index 1)", TestObject->TestArray[1], 2); + TestEqual("RemoveDuplicates should keep the first occurrence (index 2)", TestObject->TestArray[2], 3); + TestEqual("RemoveDuplicates should keep the first occurrence (index 3)", TestObject->TestArray[3], 4); + } + + TestObject->TestArray = {5, 5, 5}; + UDirectiveUtilArrayFunctionLibrary::GenericArray_RemoveDuplicates(&TestObject->TestArray, ArrayProperty); + TestEqual("RemoveDuplicates should collapse an all-duplicates array to one element", TestObject->TestArray.Num(), 1); + if (TestObject->TestArray.Num() == 1) + { + TestEqual("RemoveDuplicates should keep the single remaining value", TestObject->TestArray[0], 5); + } + + TestObject->TestArray.Empty(); + UDirectiveUtilArrayFunctionLibrary::GenericArray_RemoveDuplicates(&TestObject->TestArray, ArrayProperty); + TestEqual("RemoveDuplicates on an empty array should leave it empty", TestObject->TestArray.Num(), 0); + + TestObject->TestArray = {10, 20, 30, 40, 50}; + TArray SliceOut; + UDirectiveUtilArrayFunctionLibrary::GenericArray_Slice(&TestObject->TestArray, ArrayProperty, 1, 3, &SliceOut, ArrayProperty); + TestEqual("Slice should copy a contiguous range", SliceOut, TArray({20, 30, 40})); + UDirectiveUtilArrayFunctionLibrary::GenericArray_Slice(&TestObject->TestArray, ArrayProperty, 3, 99, &SliceOut, ArrayProperty); + TestEqual("Slice should clamp Count to the available elements", SliceOut, TArray({40, 50})); + UDirectiveUtilArrayFunctionLibrary::GenericArray_Slice(&TestObject->TestArray, ArrayProperty, -5, 2, &SliceOut, ArrayProperty); + TestEqual("Slice should clamp a negative start index to 0", SliceOut, TArray({10, 20})); + UDirectiveUtilArrayFunctionLibrary::GenericArray_Slice(&TestObject->TestArray, ArrayProperty, 0, 0, &SliceOut, ArrayProperty); + TestEqual("Slice with Count 0 should be empty", SliceOut.Num(), 0); + + TestObject->TestArray = {1, 2, 3, 4, 5}; + UDirectiveUtilArrayFunctionLibrary::GenericArray_Rotate(&TestObject->TestArray, ArrayProperty, 2); + TestEqual("Rotate by +2 should rotate toward the end", TestObject->TestArray, TArray({4, 5, 1, 2, 3})); + TestObject->TestArray = {1, 2, 3, 4, 5}; + UDirectiveUtilArrayFunctionLibrary::GenericArray_Rotate(&TestObject->TestArray, ArrayProperty, -1); + TestEqual("Rotate by -1 should rotate toward the start", TestObject->TestArray, TArray({2, 3, 4, 5, 1})); + TestObject->TestArray = {1, 2, 3}; + UDirectiveUtilArrayFunctionLibrary::GenericArray_Rotate(&TestObject->TestArray, ArrayProperty, 3); + TestEqual("Rotate by Length should be a no-op", TestObject->TestArray, TArray({1, 2, 3})); + + TestObject->TestArray = {1, 2, 2, 3, 1, 4}; + TArray DistinctOut; + UDirectiveUtilArrayFunctionLibrary::GenericArray_GetDistinct(&TestObject->TestArray, ArrayProperty, &DistinctOut, ArrayProperty); + TestEqual("GetDistinct should keep first occurrences in order", DistinctOut, TArray({1, 2, 3, 4})); + TestEqual("GetDistinct should not modify the source array", TestObject->TestArray.Num(), 6); + + TestObject->TestArray = {5, 1, 5, 2, 5, 3}; + int32 ItemToCount = 5; + TestEqual("CountOccurrences should count matches", UDirectiveUtilArrayFunctionLibrary::GenericArray_CountOccurrences(&TestObject->TestArray, ArrayProperty, &ItemToCount), 3); + int32 MissingItem = 99; + TestEqual("CountOccurrences should return 0 for an absent item", UDirectiveUtilArrayFunctionLibrary::GenericArray_CountOccurrences(&TestObject->TestArray, ArrayProperty, &MissingItem), 0); + + TestObject->TestArray = {7, 7, 8, 7, 9, 8}; + int32 MostCommonItem = -1; + int32 MostCommonCount = -1; + const bool bGotMostCommon = UDirectiveUtilArrayFunctionLibrary::GenericArray_GetMostCommon(&TestObject->TestArray, ArrayProperty, &MostCommonItem, &MostCommonCount); + TestTrue("GetMostCommon should succeed on a non-empty array", bGotMostCommon); + TestEqual("GetMostCommon should return the most frequent element", MostCommonItem, 7); + TestEqual("GetMostCommon should return the occurrence count", MostCommonCount, 3); + TestObject->TestArray.Empty(); + int32 EmptyMostItem = 5; + int32 EmptyMostCount = 5; + TestFalse("GetMostCommon should fail on an empty array", UDirectiveUtilArrayFunctionLibrary::GenericArray_GetMostCommon(&TestObject->TestArray, ArrayProperty, &EmptyMostItem, &EmptyMostCount)); + TestEqual("GetMostCommon should reset the count on an empty array", EmptyMostCount, 0); + + return true; +} diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilAsyncTaskTest.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilAsyncTaskTest.cpp new file mode 100644 index 00000000..c51ca91b --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilAsyncTaskTest.cpp @@ -0,0 +1,411 @@ +// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License. + +#include "Tasks/DirectiveUtilTask_Delay.h" +#include "Tasks/DirectiveUtilTask_AsyncLoadAsset.h" +#include "Tests/DirectiveUtilTestObject.h" +#include "Engine/World.h" +#include "Engine/Engine.h" +#include "Engine/StaticMesh.h" +#include "Engine/StaticMeshActor.h" +#include "TimerManager.h" +#include "UObject/SoftObjectPath.h" +#include "UObject/SoftObjectPtr.h" +#include "Misc/AutomationTest.h" + +#if WITH_EDITOR + +namespace DirectiveUtilAsyncTaskTestHelpers +{ + /** + * Spawns a transient world, starts a cancellable delay in it, and returns a rooted listener bound + * to the delay's Completed delegate. The world is stored on the listener so the latent command can + * tick its timer manager across frames and tear it down once the scenario settles. + * + * A timer set on a never-ticked FTimerManager is queued as pending and only promoted to active on + * the first tick; it fires on a later tick. Because a manager can be ticked at most once per frame, + * driving the timer to completion requires advancing real frames, hence the latent command below. + */ + UDirectiveUtilDelegateListener* StartDelayScenario(float Duration, bool bCancel) + { + UWorld* World = UWorld::CreateWorld(EWorldType::Editor, false); + if (!World) + { + return nullptr; + } + FWorldContext& WorldContext = GEngine->CreateNewWorldContext(EWorldType::Editor); + WorldContext.SetCurrentWorld(World); + + UDirectiveUtilDelegateListener* Listener = NewObject(); + Listener->AddToRoot(); + Listener->ScenarioWorld = World; + + UDirectiveUtilTask_Delay* Task = UDirectiveUtilTask_Delay::CancellableDelay(World, Duration); + Listener->Keepalive = Task; + Task->Completed.AddDynamic(Listener, &UDirectiveUtilDelegateListener::OnCompleted); + Task->Activate(); + + if (bCancel) + { + Task->EndTask(); + } + + return Listener; + } +} + +/** + * Latent command that ticks a delay scenario's world each frame until the delay completes or the + * frame budget runs out, then asserts against the expected outcome and tears the world down. + */ +DEFINE_LATENT_AUTOMATION_COMMAND_FOUR_PARAMETER(FDirectiveUtilTickDelayScenario, FAutomationTestBase*, Test, UDirectiveUtilDelegateListener*, Listener, int32, FramesRemaining, bool, bExpectComplete); + +bool FDirectiveUtilTickDelayScenario::Update() +{ + if (!Listener) + { + return true; + } + + UWorld* World = Listener->ScenarioWorld.Get(); + if (World) + { + World->GetTimerManager().Tick(0.1f); + } + + const bool bBudgetExhausted = (--FramesRemaining <= 0); + if (Listener->bCompleted || bBudgetExhausted) + { + if (bExpectComplete) + { + Test->TestTrue(TEXT("Cancellable delay broadcasts Completed once its timer elapses"), Listener->bCompleted); + Test->TestEqual(TEXT("Cancellable delay broadcasts Completed exactly once"), Listener->CompletedCount, 1); + } + else + { + Test->TestFalse(TEXT("EndTask cancels the delay so Completed never fires"), Listener->bCompleted); + } + + if (World) + { + GEngine->DestroyWorldContext(World); + World->DestroyWorld(false); + } + Listener->ScenarioWorld = nullptr; + Listener->Keepalive = nullptr; + Listener->RemoveFromRoot(); + return true; + } + + return false; +} + +/** + * DirectiveUtilTask_Delay: verifies the timer-backed completion fires, that EndTask cancels it before it fires, + * and that activating with a null world context is guarded rather than crashing. + */ +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilDelayTaskTest, "DirectiveUtilities.AsyncTaskDelayTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter) + +bool FDirectiveUtilDelayTaskTest::RunTest(const FString& Parameters) +{ + // The null-world activation intentionally logs a warning. + AddExpectedMessagePlain(TEXT("Cancellable Delay failed to activate. World is null."), ELogVerbosity::Warning, EAutomationExpectedMessageFlags::Contains, -1); + + // Completion path: the delay fires once its timer elapses (driven across frames by the latent command). + if (UDirectiveUtilDelegateListener* Completed = DirectiveUtilAsyncTaskTestHelpers::StartDelayScenario(0.05f, /*bCancel=*/false)) + { + ADD_LATENT_AUTOMATION_COMMAND(FDirectiveUtilTickDelayScenario(this, Completed, 600, /*bExpectComplete=*/true)); + } + else + { + AddError(TEXT("Failed to create a transient world for the delay completion scenario.")); + } + + // Cancellation path: EndTask clears the timer so Completed never fires across a short window. + if (UDirectiveUtilDelegateListener* Cancelled = DirectiveUtilAsyncTaskTestHelpers::StartDelayScenario(0.05f, /*bCancel=*/true)) + { + ADD_LATENT_AUTOMATION_COMMAND(FDirectiveUtilTickDelayScenario(this, Cancelled, 10, /*bExpectComplete=*/false)); + } + else + { + AddError(TEXT("Failed to create a transient world for the delay cancellation scenario.")); + } + + // Null world guard: activating with a null context object logs a warning and does not crash. + UDirectiveUtilTask_Delay* NullWorldTask = UDirectiveUtilTask_Delay::CancellableDelay(nullptr, 0.05f); + if (TestNotNull("CancellableDelay returns a task even with a null context", NullWorldTask)) + { + NullWorldTask->AddToRoot(); + NullWorldTask->Activate(); + NullWorldTask->RemoveFromRoot(); + } + + return true; +} + +/** Latent command that waits for an async-load listener to settle (completed or failed) or times out. */ +DEFINE_LATENT_AUTOMATION_COMMAND_THREE_PARAMETER(FDirectiveUtilWaitForAsyncLoad, FAutomationTestBase*, Test, UDirectiveUtilDelegateListener*, Listener, int32, FramesRemaining); + +bool FDirectiveUtilWaitForAsyncLoad::Update() +{ + if (!Listener) + { + return true; + } + + if (Listener->bFailed) + { + Test->AddError(TEXT("Async Load Asset reported failure while loading a valid engine asset.")); + Listener->RemoveFromRoot(); + Listener->Keepalive = nullptr; + return true; + } + + if (Listener->bCompleted) + { + Test->TestNotNull(TEXT("Async Load Asset resolved the requested asset"), Listener->LastObject.Get()); + Listener->RemoveFromRoot(); + Listener->Keepalive = nullptr; + return true; + } + + if (--FramesRemaining <= 0) + { + Test->AddError(TEXT("Async Load Asset did not complete within the frame budget.")); + Listener->RemoveFromRoot(); + Listener->Keepalive = nullptr; + return true; + } + + return false; +} + +/** + * DirectiveUtilTask_AsyncLoadAsset: verifies the null soft-reference path broadcasts Failed synchronously, and + * that loading a real engine asset eventually broadcasts Completed with the resolved object. + */ +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilAsyncLoadAssetTest, "DirectiveUtilities.AsyncTaskLoadAssetTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter) + +bool FDirectiveUtilAsyncLoadAssetTest::RunTest(const FString& Parameters) +{ + // The null soft-reference path intentionally logs a warning. + AddExpectedMessagePlain(TEXT("Async Load Asset failed to activate. The soft object reference is null."), ELogVerbosity::Warning, EAutomationExpectedMessageFlags::Contains, -1); + + // Failure path: a null soft reference broadcasts Failed synchronously on activation. + { + UDirectiveUtilDelegateListener* Listener = NewObject(); + Listener->AddToRoot(); + + UDirectiveUtilTask_AsyncLoadAsset* Task = UDirectiveUtilTask_AsyncLoadAsset::AsyncLoadAsset(nullptr, TSoftObjectPtr()); + Listener->Keepalive = Task; + Task->Completed.AddDynamic(Listener, &UDirectiveUtilDelegateListener::OnObjectCompleted); + Task->Failed.AddDynamic(Listener, &UDirectiveUtilDelegateListener::OnObjectFailed); + Task->Activate(); + + TestTrue("Null soft reference broadcasts Failed", Listener->bFailed); + TestFalse("Null soft reference does not broadcast Completed", Listener->bCompleted); + + Listener->RemoveFromRoot(); + Listener->Keepalive = nullptr; + } + + // Success path: loading a real engine asset broadcasts Completed with the resolved object. + { + UDirectiveUtilDelegateListener* Listener = NewObject(); + Listener->AddToRoot(); + + const TSoftObjectPtr SoftCube(FSoftObjectPath(TEXT("/Engine/BasicShapes/Cube.Cube"))); + UDirectiveUtilTask_AsyncLoadAsset* Task = UDirectiveUtilTask_AsyncLoadAsset::AsyncLoadAsset(nullptr, SoftCube); + Listener->Keepalive = Task; + Task->Completed.AddDynamic(Listener, &UDirectiveUtilDelegateListener::OnObjectCompleted); + Task->Failed.AddDynamic(Listener, &UDirectiveUtilDelegateListener::OnObjectFailed); + Task->Activate(); + + // The streamable completion delegate fires on a later engine tick; poll until it settles. + ADD_LATENT_AUTOMATION_COMMAND(FDirectiveUtilWaitForAsyncLoad(this, Listener, 600)); + } + + return true; +} + +/** Latent command that waits for a batch async-load listener to complete, then asserts the slot contract. */ +DEFINE_LATENT_AUTOMATION_COMMAND_THREE_PARAMETER(FDirectiveUtilWaitForBatchAsyncLoad, FAutomationTestBase*, Test, UDirectiveUtilDelegateListener*, Listener, int32, FramesRemaining); + +bool FDirectiveUtilWaitForBatchAsyncLoad::Update() +{ + if (!Listener) + { + return true; + } + + if (Listener->bCompleted) + { + Test->TestEqual(TEXT("Async Load Assets broadcasts Completed exactly once"), Listener->CompletedCount, 1); + Test->TestEqual(TEXT("Async Load Assets preserves the input slot count"), Listener->LastObjects.Num(), 3); + if (Listener->LastObjects.Num() == 3) + { + Test->TestNotNull(TEXT("Async Load Assets resolves the first asset"), Listener->LastObjects[0].Get()); + Test->TestNotNull(TEXT("Async Load Assets resolves the second asset"), Listener->LastObjects[1].Get()); + Test->TestNull(TEXT("Async Load Assets keeps a null slot for an unset reference"), Listener->LastObjects[2].Get()); + } + Listener->RemoveFromRoot(); + Listener->Keepalive = nullptr; + return true; + } + + if (--FramesRemaining <= 0) + { + Test->AddError(TEXT("Async Load Assets did not complete within the frame budget.")); + Listener->RemoveFromRoot(); + Listener->Keepalive = nullptr; + return true; + } + + return false; +} + +/** Latent command that waits a fixed frame window and then asserts a cancelled batch load never completed. */ +DEFINE_LATENT_AUTOMATION_COMMAND_THREE_PARAMETER(FDirectiveUtilVerifyCancelledBatchLoad, FAutomationTestBase*, Test, UDirectiveUtilDelegateListener*, Listener, int32, FramesRemaining); + +bool FDirectiveUtilVerifyCancelledBatchLoad::Update() +{ + if (!Listener) + { + return true; + } + + if (--FramesRemaining > 0) + { + return false; + } + + Test->TestFalse(TEXT("Cancel prevents the batch Completed broadcast"), Listener->bCompleted); + Listener->RemoveFromRoot(); + Listener->Keepalive = nullptr; + return true; +} + +/** + * DirectiveUtilTask_AsyncLoadAssets: verifies a batch resolves in input order with null slots for unset references, + * that an empty input completes immediately with an empty array, and that Cancel suppresses Completed. + */ +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilAsyncLoadAssetsTest, "DirectiveUtilities.AsyncTaskLoadAssetsTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter) + +bool FDirectiveUtilAsyncLoadAssetsTest::RunTest(const FString& Parameters) +{ + // Empty input: Completed broadcasts synchronously on activation with an empty array. + { + UDirectiveUtilDelegateListener* Listener = NewObject(); + Listener->AddToRoot(); + + UDirectiveUtilTask_AsyncLoadAssets* Task = UDirectiveUtilTask_AsyncLoadAssets::AsyncLoadAssets(nullptr, TArray>()); + Listener->Keepalive = Task; + Task->Completed.AddDynamic(Listener, &UDirectiveUtilDelegateListener::OnObjectsCompleted); + Task->Activate(); + + TestTrue("Empty batch broadcasts Completed immediately", Listener->bCompleted); + TestEqual("Empty batch broadcasts Completed exactly once", Listener->CompletedCount, 1); + TestEqual("Empty batch reports an empty array", Listener->LastObjects.Num(), 0); + + Listener->Keepalive = nullptr; + Listener->RemoveFromRoot(); + } + + // Batch path: two valid engine meshes plus an unset reference resolve in input order. + { + UDirectiveUtilDelegateListener* Listener = NewObject(); + Listener->AddToRoot(); + + TArray> Assets; + Assets.Add(TSoftObjectPtr(FSoftObjectPath(TEXT("/Engine/BasicShapes/Cube.Cube")))); + Assets.Add(TSoftObjectPtr(FSoftObjectPath(TEXT("/Engine/BasicShapes/Sphere.Sphere")))); + Assets.Add(TSoftObjectPtr()); + + UDirectiveUtilTask_AsyncLoadAssets* Task = UDirectiveUtilTask_AsyncLoadAssets::AsyncLoadAssets(nullptr, Assets); + Listener->Keepalive = Task; + Task->Completed.AddDynamic(Listener, &UDirectiveUtilDelegateListener::OnObjectsCompleted); + Task->Activate(); + + // The streamable completion delegate fires on a later engine tick; poll until it settles. + ADD_LATENT_AUTOMATION_COMMAND(FDirectiveUtilWaitForBatchAsyncLoad(this, Listener, 600)); + } + + // Cancel path: cancelling before completion suppresses the Completed broadcast. Uses a mesh no + // other test loads so the request is genuinely in flight when Cancel arrives; if the asset is + // already in memory the batch completes synchronously and there is nothing left to cancel. + { + UDirectiveUtilDelegateListener* Listener = NewObject(); + Listener->AddToRoot(); + + TArray> Assets; + Assets.Add(TSoftObjectPtr(FSoftObjectPath(TEXT("/Engine/EngineMeshes/SM_MatPreviewMesh_01.SM_MatPreviewMesh_01")))); + + UDirectiveUtilTask_AsyncLoadAssets* Task = UDirectiveUtilTask_AsyncLoadAssets::AsyncLoadAssets(nullptr, Assets); + Listener->Keepalive = Task; + Task->Completed.AddDynamic(Listener, &UDirectiveUtilDelegateListener::OnObjectsCompleted); + Task->Activate(); + + if (Listener->bCompleted) + { + AddInfo(TEXT("Batch load completed synchronously (asset already in memory); skipping the cancel scenario.")); + Listener->Keepalive = nullptr; + Listener->RemoveFromRoot(); + } + else + { + Task->Cancel(); + ADD_LATENT_AUTOMATION_COMMAND(FDirectiveUtilVerifyCancelledBatchLoad(this, Listener, 10)); + } + } + + return true; +} + +/** + * DirectiveUtilTask_AsyncLoadClass: verifies the null soft-class path broadcasts Failed synchronously, and that + * loading a real class broadcasts Completed with the resolved class. + */ +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilAsyncLoadClassTest, "DirectiveUtilities.AsyncTaskLoadClassTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter) + +bool FDirectiveUtilAsyncLoadClassTest::RunTest(const FString& Parameters) +{ + // The null soft-class path intentionally logs a warning. + AddExpectedMessagePlain(TEXT("Async Load Class failed to activate. The soft class reference is null."), ELogVerbosity::Warning, EAutomationExpectedMessageFlags::Contains, -1); + + // Failure path: a null soft class reference broadcasts Failed synchronously on activation. + { + UDirectiveUtilDelegateListener* Listener = NewObject(); + Listener->AddToRoot(); + + UDirectiveUtilTask_AsyncLoadClass* Task = UDirectiveUtilTask_AsyncLoadClass::AsyncLoadClass(nullptr, TSoftClassPtr()); + Listener->Keepalive = Task; + Task->Completed.AddDynamic(Listener, &UDirectiveUtilDelegateListener::OnClassCompleted); + Task->Failed.AddDynamic(Listener, &UDirectiveUtilDelegateListener::OnClassFailed); + Task->Activate(); + + TestTrue("Null soft class broadcasts Failed", Listener->bFailed); + TestFalse("Null soft class does not broadcast Completed", Listener->bCompleted); + + Listener->RemoveFromRoot(); + Listener->Keepalive = nullptr; + } + + // Success path: loading a real class broadcasts Completed with the resolved class. + { + UDirectiveUtilDelegateListener* Listener = NewObject(); + Listener->AddToRoot(); + + const TSoftClassPtr SoftClass(AStaticMeshActor::StaticClass()); + UDirectiveUtilTask_AsyncLoadClass* Task = UDirectiveUtilTask_AsyncLoadClass::AsyncLoadClass(nullptr, SoftClass); + Listener->Keepalive = Task; + Task->Completed.AddDynamic(Listener, &UDirectiveUtilDelegateListener::OnClassCompleted); + Task->Failed.AddDynamic(Listener, &UDirectiveUtilDelegateListener::OnClassFailed); + Task->Activate(); + + // The streamable completion delegate fires on a later engine tick; poll until it settles. + ADD_LATENT_AUTOMATION_COMMAND(FDirectiveUtilWaitForAsyncLoad(this, Listener, 600)); + } + + return true; +} + +#endif // WITH_EDITOR diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilAsyncTraceTest.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilAsyncTraceTest.cpp new file mode 100644 index 00000000..0d1f2c64 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilAsyncTraceTest.cpp @@ -0,0 +1,502 @@ +// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License. + +#include "Tasks/DirectiveUtilTask_AsyncTrace.h" +#include "Tasks/DirectiveUtilTask_MoveToLocation.h" +#include "Tests/DirectiveUtilTestObject.h" +#include "Engine/World.h" +#include "Engine/Engine.h" +#include "Engine/EngineBaseTypes.h" +#include "Engine/EngineTypes.h" +#include "Engine/StaticMesh.h" +#include "Engine/StaticMeshActor.h" +#include "Components/StaticMeshComponent.h" +#include "GameFramework/DefaultPawn.h" +#include "GameFramework/PlayerController.h" +#include "Misc/AutomationTest.h" + +#if WITH_EDITOR + +namespace DirectiveUtilAsyncTraceTestHelpers +{ + /** Creates a transient game world with a physics scene, initialized for play so traces resolve. */ + UWorld* CreateTraceWorld() + { + UWorld* World = UWorld::CreateWorld(EWorldType::Game, false); + if (!World) + { + return nullptr; + } + FWorldContext& WorldContext = GEngine->CreateNewWorldContext(EWorldType::Game); + WorldContext.SetCurrentWorld(World); + World->InitializeActorsForPlay(FURL()); + World->BeginPlay(); + return World; + } + + /** Spawns a blocking cube actor at the origin so traces have something to hit. */ + AStaticMeshActor* SpawnBlockingCube(UWorld* World, UStaticMesh* CubeMesh) + { + AStaticMeshActor* Cube = World->SpawnActor(FVector::ZeroVector, FRotator::ZeroRotator); + UStaticMeshComponent* Component = Cube->GetStaticMeshComponent(); + Component->SetMobility(EComponentMobility::Movable); + Component->SetStaticMesh(CubeMesh); + Component->SetCollisionProfileName(TEXT("BlockAll")); + Component->SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics); + Component->UpdateCollisionProfile(); + return Cube; + } +} + +/** + * Latent command that ticks a trace world each frame until every listener has reported completion + * (or the frame budget runs out), asserts the expected outcome, and tears the world down. + */ +class FDirectiveUtilTickTraceWorld : public IAutomationLatentCommand +{ +public: + FDirectiveUtilTickTraceWorld(FAutomationTestBase* InTest, UWorld* InWorld, const TArray& InListeners, int32 InFrames) + : Test(InTest) + , World(InWorld) + , Listeners(InListeners) + , FramesRemaining(InFrames) + { + } + + virtual bool Update() override + { + if (UWorld* TickWorld = World.Get()) + { + TickWorld->Tick(LEVELTICK_All, 0.05f); + } + + bool bAllComplete = true; + for (const UDirectiveUtilDelegateListener* Listener : Listeners) + { + if (Listener && !Listener->bCompleted) + { + bAllComplete = false; + break; + } + } + + if (bAllComplete || --FramesRemaining <= 0) + { + for (UDirectiveUtilDelegateListener* Listener : Listeners) + { + if (!Listener) + { + continue; + } + Test->TestTrue(TEXT("Async trace broadcasts Completed"), Listener->bCompleted); + Test->TestTrue(TEXT("Async trace through a blocking cube reports a hit"), Listener->HitCount > 0); + Listener->Keepalive = nullptr; + Listener->RemoveFromRoot(); + } + + if (UWorld* TearDownWorld = World.Get()) + { + GEngine->DestroyWorldContext(TearDownWorld); + TearDownWorld->DestroyWorld(false); + } + return true; + } + + return false; + } + +private: + FAutomationTestBase* Test; + TWeakObjectPtr World; + TArray Listeners; + int32 FramesRemaining; +}; + +/** + * DirectiveUtilTask_AsyncTrace: verifies the null-world guard broadcasts an empty result, and that each trace + * shape (line, sphere, box, capsule) resolves against a blocking body and reports a hit. + */ +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilAsyncTraceTest, "DirectiveUtilities.AsyncTaskTraceTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter) + +bool FDirectiveUtilAsyncTraceTest::RunTest(const FString& Parameters) +{ + // The null-world activation intentionally logs a warning. + AddExpectedMessagePlain(TEXT("Async Trace failed to activate. World is null."), ELogVerbosity::Warning, EAutomationExpectedMessageFlags::Contains, -1); + + // Null world guard: activating with a null context broadcasts an empty result and does not crash. + { + UDirectiveUtilDelegateListener* Listener = NewObject(); + Listener->AddToRoot(); + + UDirectiveUtilTask_AsyncTrace* Task = UDirectiveUtilTask_AsyncTrace::AsyncLineTraceByChannel(nullptr, FVector::ZeroVector, FVector(0, 0, 100), ETraceTypeQuery::TraceTypeQuery1, false); + Listener->Keepalive = Task; + Task->Completed.AddDynamic(Listener, &UDirectiveUtilDelegateListener::OnTraceCompleted); + Task->Activate(); + + TestTrue("Null world trace still broadcasts Completed", Listener->bCompleted); + TestEqual("Null world trace reports no hits", Listener->HitCount, 0); + + Listener->Keepalive = nullptr; + Listener->RemoveFromRoot(); + } + + UStaticMesh* CubeMesh = LoadObject(nullptr, TEXT("/Engine/BasicShapes/Cube.Cube")); + if (!CubeMesh) + { + AddInfo(TEXT("Engine cube mesh unavailable; skipping async trace hit scenarios.")); + return true; + } + + UWorld* World = DirectiveUtilAsyncTraceTestHelpers::CreateTraceWorld(); + if (!World) + { + AddError(TEXT("Failed to create a transient game world for the async trace test.")); + return false; + } + DirectiveUtilAsyncTraceTestHelpers::SpawnBlockingCube(World, CubeMesh); + + // Trace straight down through the cube at the origin so every shape intersects it. + const FVector Start(0.0f, 0.0f, 500.0f); + const FVector End(0.0f, 0.0f, -500.0f); + const ETraceTypeQuery Channel = ETraceTypeQuery::TraceTypeQuery1; // Visibility, which BlockAll blocks. + + auto MakeListener = [](UDirectiveUtilTask_AsyncTrace* Task) -> UDirectiveUtilDelegateListener* + { + UDirectiveUtilDelegateListener* Listener = NewObject(); + Listener->AddToRoot(); + Listener->Keepalive = Task; + Task->Completed.AddDynamic(Listener, &UDirectiveUtilDelegateListener::OnTraceCompleted); + Task->Activate(); + return Listener; + }; + + TArray Listeners; + Listeners.Add(MakeListener(UDirectiveUtilTask_AsyncTrace::AsyncLineTraceByChannel(World, Start, End, Channel, false))); + Listeners.Add(MakeListener(UDirectiveUtilTask_AsyncTrace::AsyncSphereTraceByChannel(World, Start, End, 25.0f, Channel, false))); + Listeners.Add(MakeListener(UDirectiveUtilTask_AsyncTrace::AsyncBoxTraceByChannel(World, Start, End, FVector(25.0f), FRotator::ZeroRotator, Channel, false))); + Listeners.Add(MakeListener(UDirectiveUtilTask_AsyncTrace::AsyncCapsuleTraceByChannel(World, Start, End, 25.0f, 50.0f, Channel, false))); + + ADD_LATENT_AUTOMATION_COMMAND(FDirectiveUtilTickTraceWorld(this, World, Listeners, 120)); + + return true; +} + +/** + * Latent command that ticks a move-to-location world each frame until the listener reports + * completion (or the frame budget runs out), asserts a single failed completion, and tears the + * world down. + */ +class FDirectiveUtilTickMoveToLocationWorld : public IAutomationLatentCommand +{ +public: + FDirectiveUtilTickMoveToLocationWorld(FAutomationTestBase* InTest, UWorld* InWorld, UDirectiveUtilDelegateListener* InListener, int32 InFrames) + : Test(InTest) + , World(InWorld) + , Listener(InListener) + , FramesRemaining(InFrames) + { + } + + virtual bool Update() override + { + if (UWorld* TickWorld = World.Get()) + { + TickWorld->Tick(LEVELTICK_All, 0.05f); + } + + if (Listener && !Listener->bCompleted && --FramesRemaining > 0) + { + return false; + } + + if (Listener) + { + Test->TestTrue(TEXT("Move without navigation broadcasts Completed"), Listener->bCompleted); + Test->TestFalse(TEXT("Move without navigation reports failure"), Listener->bLastSuccess); + Test->TestEqual(TEXT("Move without navigation completes exactly once"), Listener->CompletedCount, 1); + Listener->Keepalive = nullptr; + Listener->RemoveFromRoot(); + } + + if (UWorld* TearDownWorld = World.Get()) + { + GEngine->DestroyWorldContext(TearDownWorld); + TearDownWorld->DestroyWorld(false); + } + return true; + } + +private: + FAutomationTestBase* Test; + TWeakObjectPtr World; + UDirectiveUtilDelegateListener* Listener; + int32 FramesRemaining; +}; + +/** + * DirectiveUtilTask_MoveToLocation: verifies the guard paths (null controller, controller without a pawn) and + * EndTask all broadcast Completed(false) without crashing, that a second EndTask does not broadcast + * again, and that a move with no navigation data terminates with failure. The successful navigation + * path requires a built navigation mesh and is exercised in a project-level test rather than here. + */ +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilMoveToLocationTest, "DirectiveUtilities.AsyncTaskMoveToLocationTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter) + +bool FDirectiveUtilMoveToLocationTest::RunTest(const FString& Parameters) +{ + // The guard paths intentionally log a warning. + AddExpectedMessagePlain(TEXT("Controller or pawn has been destroyed while moving to location. Aborting."), ELogVerbosity::Warning, EAutomationExpectedMessageFlags::Contains, -1); + + // Null controller guard: activating broadcasts Completed(false). + { + UDirectiveUtilDelegateListener* Listener = NewObject(); + Listener->AddToRoot(); + + UDirectiveUtilTask_MoveToLocation* Task = UDirectiveUtilTask_MoveToLocation::MoveToLocation(nullptr, nullptr, FVector(100.0f, 0.0f, 0.0f)); + Listener->Keepalive = Task; + Task->Completed.AddDynamic(Listener, &UDirectiveUtilDelegateListener::OnBoolCompleted); + Task->Activate(); + + TestTrue("Null controller broadcasts Completed", Listener->bCompleted); + TestFalse("Null controller reports failure", Listener->bLastSuccess); + + Listener->Keepalive = nullptr; + Listener->RemoveFromRoot(); + } + + UWorld* World = UWorld::CreateWorld(EWorldType::Editor, false); + if (!World) + { + AddError(TEXT("Failed to create a transient world for the move-to-location test.")); + return false; + } + FWorldContext& WorldContext = GEngine->CreateNewWorldContext(EWorldType::Editor); + WorldContext.SetCurrentWorld(World); + + // Controller-without-pawn guard: activating broadcasts Completed(false). + { + APlayerController* Controller = World->SpawnActor(); + + UDirectiveUtilDelegateListener* Listener = NewObject(); + Listener->AddToRoot(); + + UDirectiveUtilTask_MoveToLocation* Task = UDirectiveUtilTask_MoveToLocation::MoveToLocation(World, Controller, FVector(100.0f, 0.0f, 0.0f)); + Listener->Keepalive = Task; + Task->Completed.AddDynamic(Listener, &UDirectiveUtilDelegateListener::OnBoolCompleted); + Task->Activate(); + + TestTrue("Controller without a pawn broadcasts Completed", Listener->bCompleted); + TestFalse("Controller without a pawn reports failure", Listener->bLastSuccess); + + Listener->Keepalive = nullptr; + Listener->RemoveFromRoot(); + } + + // EndTask broadcasts Completed(false) and clears timers without crashing. + { + APlayerController* Controller = World->SpawnActor(); + + UDirectiveUtilDelegateListener* Listener = NewObject(); + Listener->AddToRoot(); + + UDirectiveUtilTask_MoveToLocation* Task = UDirectiveUtilTask_MoveToLocation::MoveToLocation(World, Controller, FVector(100.0f, 0.0f, 0.0f)); + Listener->Keepalive = Task; + Task->Completed.AddDynamic(Listener, &UDirectiveUtilDelegateListener::OnBoolCompleted); + Task->EndTask(); + + TestTrue("EndTask broadcasts Completed", Listener->bCompleted); + TestFalse("EndTask reports failure", Listener->bLastSuccess); + + Listener->Keepalive = nullptr; + Listener->RemoveFromRoot(); + } + + // Double completion guard: a second EndTask does not broadcast Completed again. + { + APlayerController* Controller = World->SpawnActor(); + + UDirectiveUtilDelegateListener* Listener = NewObject(); + Listener->AddToRoot(); + + UDirectiveUtilTask_MoveToLocation* Task = UDirectiveUtilTask_MoveToLocation::MoveToLocation(World, Controller, FVector(100.0f, 0.0f, 0.0f)); + Listener->Keepalive = Task; + Task->Completed.AddDynamic(Listener, &UDirectiveUtilDelegateListener::OnBoolCompleted); + Task->EndTask(); + Task->EndTask(); + + TestEqual("Double EndTask broadcasts Completed exactly once", Listener->CompletedCount, 1); + + Listener->Keepalive = nullptr; + Listener->RemoveFromRoot(); + } + + GEngine->DestroyWorldContext(World); + World->DestroyWorld(false); + + // No-navigation failure: without a navmesh the idle path-following check terminates the task + // with failure instead of polling forever. + { + // SimpleMoveToLocation may warn when the world has no navigation system. + AddExpectedMessagePlain(TEXT("SimpleMoveToActor called for NavSys:"), ELogVerbosity::Warning, EAutomationExpectedMessageFlags::Contains, -1); + AddExpectedMessagePlain(TEXT("SimpleMove failed for"), ELogVerbosity::Warning, EAutomationExpectedMessageFlags::Contains, -1); + + UWorld* MoveWorld = DirectiveUtilAsyncTraceTestHelpers::CreateTraceWorld(); + if (!MoveWorld) + { + AddInfo(TEXT("Failed to create a transient game world; skipping the no-navigation move scenario.")); + return true; + } + + APlayerController* Controller = MoveWorld->SpawnActor(); + ADefaultPawn* Pawn = MoveWorld->SpawnActor(FVector::ZeroVector, FRotator::ZeroRotator); + if (!Controller || !Pawn) + { + AddInfo(TEXT("Failed to spawn a controller or pawn; skipping the no-navigation move scenario.")); + GEngine->DestroyWorldContext(MoveWorld); + MoveWorld->DestroyWorld(false); + return true; + } + Controller->SetPawn(Pawn); + + UDirectiveUtilDelegateListener* Listener = NewObject(); + Listener->AddToRoot(); + + UDirectiveUtilTask_MoveToLocation* Task = UDirectiveUtilTask_MoveToLocation::MoveToLocation(MoveWorld, Controller, FVector(10000.0f, 0.0f, 0.0f), 100.0f, false); + Listener->Keepalive = Task; + Task->Completed.AddDynamic(Listener, &UDirectiveUtilDelegateListener::OnBoolCompleted); + Task->Activate(); + + ADD_LATENT_AUTOMATION_COMMAND(FDirectiveUtilTickMoveToLocationWorld(this, MoveWorld, Listener, 120)); + } + + return true; +} + +/** + * DirectiveUtilTask_MoveToActor: verifies the guard paths (null controller, null goal) broadcast Completed(false) + * without crashing, that a second EndTask does not broadcast again, and that a move with no + * navigation data terminates with failure. The successful navigation path requires a built + * navigation mesh and is exercised in a project-level test rather than here. + */ +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilMoveToActorTest, "DirectiveUtilities.AsyncTaskMoveToActorTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter) + +bool FDirectiveUtilMoveToActorTest::RunTest(const FString& Parameters) +{ + // The guard paths intentionally log a warning. + AddExpectedMessagePlain(TEXT("Controller, pawn, or goal has been destroyed while moving to actor. Aborting."), ELogVerbosity::Warning, EAutomationExpectedMessageFlags::Contains, -1); + + // Null controller guard: activating broadcasts Completed(false). + { + UDirectiveUtilDelegateListener* Listener = NewObject(); + Listener->AddToRoot(); + + UDirectiveUtilTask_MoveToActor* Task = UDirectiveUtilTask_MoveToActor::MoveToActor(nullptr, nullptr, nullptr); + Listener->Keepalive = Task; + Task->Completed.AddDynamic(Listener, &UDirectiveUtilDelegateListener::OnBoolCompleted); + Task->Activate(); + + TestTrue("Null controller broadcasts Completed", Listener->bCompleted); + TestFalse("Null controller reports failure", Listener->bLastSuccess); + + Listener->Keepalive = nullptr; + Listener->RemoveFromRoot(); + } + + UWorld* World = UWorld::CreateWorld(EWorldType::Editor, false); + if (!World) + { + AddError(TEXT("Failed to create a transient world for the move-to-actor test.")); + return false; + } + FWorldContext& WorldContext = GEngine->CreateNewWorldContext(EWorldType::Editor); + WorldContext.SetCurrentWorld(World); + + // Null goal guard: a controller with a pawn but no goal broadcasts Completed(false). + { + APlayerController* Controller = World->SpawnActor(); + ADefaultPawn* Pawn = World->SpawnActor(FVector::ZeroVector, FRotator::ZeroRotator); + if (Controller && Pawn) + { + Controller->SetPawn(Pawn); + + UDirectiveUtilDelegateListener* Listener = NewObject(); + Listener->AddToRoot(); + + UDirectiveUtilTask_MoveToActor* Task = UDirectiveUtilTask_MoveToActor::MoveToActor(World, Controller, nullptr); + Listener->Keepalive = Task; + Task->Completed.AddDynamic(Listener, &UDirectiveUtilDelegateListener::OnBoolCompleted); + Task->Activate(); + + TestTrue("Null goal broadcasts Completed", Listener->bCompleted); + TestFalse("Null goal reports failure", Listener->bLastSuccess); + + Listener->Keepalive = nullptr; + Listener->RemoveFromRoot(); + } + else + { + AddInfo(TEXT("Failed to spawn a controller or pawn; skipping the null-goal scenario.")); + } + } + + // Double completion guard: a second EndTask does not broadcast Completed again. + { + APlayerController* Controller = World->SpawnActor(); + + UDirectiveUtilDelegateListener* Listener = NewObject(); + Listener->AddToRoot(); + + UDirectiveUtilTask_MoveToActor* Task = UDirectiveUtilTask_MoveToActor::MoveToActor(World, Controller, nullptr); + Listener->Keepalive = Task; + Task->Completed.AddDynamic(Listener, &UDirectiveUtilDelegateListener::OnBoolCompleted); + Task->EndTask(); + Task->EndTask(); + + TestEqual("Double EndTask broadcasts Completed exactly once", Listener->CompletedCount, 1); + + Listener->Keepalive = nullptr; + Listener->RemoveFromRoot(); + } + + GEngine->DestroyWorldContext(World); + World->DestroyWorld(false); + + // No-navigation failure: without a navmesh the idle path-following check terminates the task + // with failure instead of polling forever. + { + // SimpleMoveToActor may warn when the world has no navigation system. + AddExpectedMessagePlain(TEXT("SimpleMoveToActor called for NavSys:"), ELogVerbosity::Warning, EAutomationExpectedMessageFlags::Contains, -1); + AddExpectedMessagePlain(TEXT("SimpleMove failed for"), ELogVerbosity::Warning, EAutomationExpectedMessageFlags::Contains, -1); + + UWorld* MoveWorld = DirectiveUtilAsyncTraceTestHelpers::CreateTraceWorld(); + if (!MoveWorld) + { + AddInfo(TEXT("Failed to create a transient game world; skipping the no-navigation move scenario.")); + return true; + } + + APlayerController* Controller = MoveWorld->SpawnActor(); + ADefaultPawn* Pawn = MoveWorld->SpawnActor(FVector::ZeroVector, FRotator::ZeroRotator); + AStaticMeshActor* GoalActor = MoveWorld->SpawnActor(FVector(10000.0f, 0.0f, 0.0f), FRotator::ZeroRotator); + if (!Controller || !Pawn || !GoalActor) + { + AddInfo(TEXT("Failed to spawn a controller, pawn, or goal; skipping the no-navigation move scenario.")); + GEngine->DestroyWorldContext(MoveWorld); + MoveWorld->DestroyWorld(false); + return true; + } + Controller->SetPawn(Pawn); + + UDirectiveUtilDelegateListener* Listener = NewObject(); + Listener->AddToRoot(); + + UDirectiveUtilTask_MoveToActor* Task = UDirectiveUtilTask_MoveToActor::MoveToActor(MoveWorld, Controller, GoalActor, 100.0f, false); + Listener->Keepalive = Task; + Task->Completed.AddDynamic(Listener, &UDirectiveUtilDelegateListener::OnBoolCompleted); + Task->Activate(); + + ADD_LATENT_AUTOMATION_COMMAND(FDirectiveUtilTickMoveToLocationWorld(this, MoveWorld, Listener, 120)); + } + + return true; +} + +#endif // WITH_EDITOR diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilBlueprintCategoryTest.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilBlueprintCategoryTest.cpp new file mode 100644 index 00000000..bc7b0c8f --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilBlueprintCategoryTest.cpp @@ -0,0 +1,52 @@ +// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License. + +#include "Misc/AutomationTest.h" +#include "UObject/UObjectIterator.h" + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilBlueprintCategoryTest, "DirectiveUtilities.BlueprintCategoryTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter) + +bool FDirectiveUtilBlueprintCategoryTest::RunTest(const FString& Parameters) +{ + const FString ExpectedCategoryRoot = TEXT("Directive Utilities"); + const TSet PluginScriptPackages = { + TEXT("/Script/DirectiveUtilitiesRuntime"), + TEXT("/Script/DirectiveUtilitiesEditor") + }; + + int32 TestedFunctionCount = 0; + + for (TObjectIterator ClassIterator; ClassIterator; ++ClassIterator) + { + UClass* Class = *ClassIterator; + if (!PluginScriptPackages.Contains(Class->GetOutermost()->GetName())) + { + continue; + } + + for (TFieldIterator FunctionIterator(Class, EFieldIteratorFlags::ExcludeSuper); FunctionIterator; ++FunctionIterator) + { + const UFunction* Function = *FunctionIterator; + if (!Function->HasAnyFunctionFlags(FUNC_BlueprintCallable | FUNC_BlueprintPure)) + { + continue; + } + + ++TestedFunctionCount; + const FString Category = Function->GetMetaData(TEXT("Category")); + FString CategoryRoot = Category; + int32 CategoryDelimiterIndex = INDEX_NONE; + if (CategoryRoot.FindChar(TEXT('|'), CategoryDelimiterIndex)) + { + CategoryRoot.LeftInline(CategoryDelimiterIndex); + } + CategoryRoot.TrimStartAndEndInline(); + TestTrue( + FString::Printf(TEXT("%s.%s uses the Directive Utilities category root"), *Class->GetName(), *Function->GetName()), + CategoryRoot == ExpectedCategoryRoot + ); + } + } + + TestTrue(TEXT("Blueprint-exposed plugin functions were found"), TestedFunctionCount > 0); + return !HasAnyErrors(); +} diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilEditorActorSubsystemTest.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilEditorActorSubsystemTest.cpp new file mode 100644 index 00000000..ffe1a811 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilEditorActorSubsystemTest.cpp @@ -0,0 +1,461 @@ +#if WITH_EDITOR + +#include "Subsystems/DirectiveUtilEditorActorSubsystem.h" +#include "Types/DirectiveUtilEditorTypes.h" +#include "Misc/AutomationTest.h" +#include "Engine/StaticMesh.h" +#include "Engine/StaticMeshActor.h" +#include "Components/StaticMeshComponent.h" +#include "Components/BoxComponent.h" +#include "Components/CapsuleComponent.h" +#include "Materials/MaterialInterface.h" +#include "Engine/World.h" +#include "Engine/Engine.h" + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilEditorActorSubsystemTest, "DirectiveUtilities.EditorActorSubsystemTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter) + +bool FDirectiveUtilEditorActorSubsystemTest::RunTest(const FString& Parameters) +{ + // IsActorWithinBoxBounds should not crash and should return false with null Actor + TestFalse("IsActorWithinBoxBounds should return false with null Actor", + UDirectiveUtilEditorActorSubsystem::IsActorWithinBoxBounds(nullptr, nullptr)); + + // IsActorWithinSphereBounds should not crash and should return false with null Actor + TestFalse("IsActorWithinSphereBounds should return false with null Actor", + UDirectiveUtilEditorActorSubsystem::IsActorWithinSphereBounds(nullptr, nullptr)); + + // IsActorWithinCapsuleBounds should not crash and should return false with null Actor + TestFalse("IsActorWithinCapsuleBounds should return false with null CapsuleComponent", + UDirectiveUtilEditorActorSubsystem::IsActorWithinCapsuleBounds(nullptr, nullptr)); + + // FilterEmptyActors should not crash with an empty array + TArray EmptyActors; + TArray FilteredActors; + UDirectiveUtilEditorActorSubsystem::FilterEmptyActors(EmptyActors, FilteredActors, Include); + TestEqual("FilterEmptyActors with empty input should produce empty output", FilteredActors.Num(), 0); + + // FilterActorsByMaterialName should not crash with an empty array + TArray MaterialFiltered; + UDirectiveUtilEditorActorSubsystem::FilterActorsByMaterialName(EmptyActors, MaterialFiltered, TEXT("TestMaterial"), OverrideOnly, Include); + TestEqual("FilterActorsByMaterialName with empty input should produce empty output", MaterialFiltered.Num(), 0); + + // FilterActorsByVertCount should not crash with an empty array + TArray VertFiltered; + UDirectiveUtilEditorActorSubsystem::FilterActorsByVertCount(EmptyActors, VertFiltered, 0, 1000, Include); + TestEqual("FilterActorsByVertCount with empty input should produce empty output", VertFiltered.Num(), 0); + + // FilterActorsByBounds should not crash with an empty array + TArray BoundsFiltered; + UDirectiveUtilEditorActorSubsystem::FilterActorsByBounds(EmptyActors, BoundsFiltered, FVector::ZeroVector, FVector::OneVector, Include); + TestEqual("FilterActorsByBounds with empty input should produce empty output", BoundsFiltered.Num(), 0); + + return true; +} + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilEditorActorSubsystemFilterTest, "DirectiveUtilities.EditorActorSubsystemFilterTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter) + +bool FDirectiveUtilEditorActorSubsystemFilterTest::RunTest(const FString& Parameters) +{ + UStaticMesh* Cube = LoadObject(nullptr, TEXT("/Engine/BasicShapes/Cube.Cube")); + UStaticMesh* Sphere = LoadObject(nullptr, TEXT("/Engine/BasicShapes/Sphere.Sphere")); + if (!Cube || !Sphere) + { + AddInfo(TEXT("Engine basic shapes unavailable; skipping include/exclude behaviour test.")); + return true; + } + + UWorld* World = UWorld::CreateWorld(EWorldType::Editor, false); + if (!World) + { + AddError(TEXT("Failed to create a transient world for the filter test.")); + return false; + } + FWorldContext& WorldContext = GEngine->CreateNewWorldContext(EWorldType::Editor); + WorldContext.SetCurrentWorld(World); + + auto SpawnWithMeshes = [World](const TArray& Meshes) -> AActor* + { + AActor* Actor = World->SpawnActor(); + USceneComponent* Root = NewObject(Actor); + Actor->SetRootComponent(Root); + Root->RegisterComponent(); + for (UStaticMesh* Mesh : Meshes) + { + UStaticMeshComponent* MeshComponent = NewObject(Actor); + MeshComponent->SetupAttachment(Root); + MeshComponent->RegisterComponent(); + MeshComponent->SetStaticMesh(Mesh); + Actor->AddInstanceComponent(MeshComponent); + } + return Actor; + }; + + AActor* ActorCubeAndSphere = SpawnWithMeshes({ Cube, Sphere }); + AActor* ActorCubeOnly = SpawnWithMeshes({ Cube }); + AActor* ActorNoMesh = SpawnWithMeshes({}); + const TArray Source = { ActorCubeAndSphere, ActorCubeOnly, ActorNoMesh }; + + // Include: actors that contain the cube mesh. + TArray Included; + UDirectiveUtilEditorActorSubsystem::FilterActorsByStaticMesh(Source, Included, Cube, Include); + TestTrue("Include: multi-mesh actor containing the cube is included", Included.Contains(ActorCubeAndSphere)); + TestTrue("Include: cube-only actor is included", Included.Contains(ActorCubeOnly)); + TestFalse("Include: actor with no mesh is excluded", Included.Contains(ActorNoMesh)); + + // Exclude: actors that do NOT contain the cube. A multi-mesh actor that uses the cube in one slot + // must still be excluded even though another slot uses a different mesh (the aggregation fix). + TArray Excluded; + UDirectiveUtilEditorActorSubsystem::FilterActorsByStaticMesh(Source, Excluded, Cube, Exclude); + TestFalse("Exclude: multi-mesh actor containing the cube is not mistakenly included", Excluded.Contains(ActorCubeAndSphere)); + TestFalse("Exclude: cube-only actor is excluded", Excluded.Contains(ActorCubeOnly)); + TestTrue("Exclude: actor with no mesh is included", Excluded.Contains(ActorNoMesh)); + + GEngine->DestroyWorldContext(World); + World->DestroyWorld(false); + + return true; +} + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilEditorActorSubsystemFilterCoverageTest, "DirectiveUtilities.EditorActorSubsystemFilterCoverageTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter) + +bool FDirectiveUtilEditorActorSubsystemFilterCoverageTest::RunTest(const FString& Parameters) +{ + UStaticMesh* Cube = LoadObject(nullptr, TEXT("/Engine/BasicShapes/Cube.Cube")); + UStaticMesh* Sphere = LoadObject(nullptr, TEXT("/Engine/BasicShapes/Sphere.Sphere")); + UMaterialInterface* MatA = LoadObject(nullptr, TEXT("/Engine/BasicShapes/BasicShapeMaterial.BasicShapeMaterial")); + UMaterialInterface* MatB = LoadObject(nullptr, TEXT("/Engine/EngineMaterials/WorldGridMaterial.WorldGridMaterial")); + if (!Cube || !Sphere || !MatA || !MatB) + { + AddInfo(TEXT("Engine basic shapes/materials unavailable; skipping filter coverage test.")); + return true; + } + + UWorld* World = UWorld::CreateWorld(EWorldType::Editor, false); + if (!World) + { + AddError(TEXT("Failed to create a transient world for the filter coverage test.")); + return false; + } + FWorldContext& WorldContext = GEngine->CreateNewWorldContext(EWorldType::Editor); + WorldContext.SetCurrentWorld(World); + + auto SpawnMesh = [World](UStaticMesh* Mesh, EComponentMobility::Type Mobility) -> UStaticMeshComponent* + { + AActor* Actor = World->SpawnActor(); + USceneComponent* Root = NewObject(Actor); + Root->SetMobility(Mobility); + Actor->SetRootComponent(Root); + Root->RegisterComponent(); + UStaticMeshComponent* MeshComponent = NewObject(Actor); + MeshComponent->SetMobility(Mobility); + MeshComponent->SetupAttachment(Root); + if (Mesh) { MeshComponent->SetStaticMesh(Mesh); } + MeshComponent->RegisterComponent(); + Actor->AddInstanceComponent(MeshComponent); + return MeshComponent; + }; + + // Actor A: cube, MatA override, Static mobility, BlockAll collision, tag Alpha, at origin. + UStaticMeshComponent* CompA = SpawnMesh(Cube, EComponentMobility::Static); + AActor* ActorA = CompA->GetOwner(); + CompA->SetMaterial(0, MatA); + CompA->SetCollisionProfileName(TEXT("BlockAll")); + ActorA->Tags.Add(FName("Alpha")); + ActorA->SetActorLocation(FVector::ZeroVector); + + // Actor B: sphere, MatB override, Movable mobility, far away, no tag. + UStaticMeshComponent* CompB = SpawnMesh(Sphere, EComponentMobility::Movable); + AActor* ActorB = CompB->GetOwner(); + CompB->SetMaterial(0, MatB); + ActorB->SetActorLocation(FVector(100000.0f, 0.0f, 0.0f)); + + // Actor C: no static mesh component at all. + AActor* ActorC = World->SpawnActor(); + USceneComponent* RootC = NewObject(ActorC); + ActorC->SetRootComponent(RootC); + RootC->RegisterComponent(); + + const TArray Src = { ActorA, ActorB, ActorC }; + auto RunFilter = [&Src](TFunctionRef&, TArray&)> Fn) -> TArray + { + TArray Out; + Fn(Src, Out); + return Out; + }; + + // Class: everything is an AActor. + TestEqual("ByClass(AActor) includes all", RunFilter([](const TArray& S, TArray& O){ UDirectiveUtilEditorActorSubsystem::FilterActorsByClass(S, O, AActor::StaticClass(), Include); }).Num(), 3); + + // Tag (Include + Exclude complement). + { + const TArray Inc = RunFilter([](const TArray& S, TArray& O){ UDirectiveUtilEditorActorSubsystem::FilterActorsByTag(S, O, FName("Alpha"), Include); }); + const TArray Exc = RunFilter([](const TArray& S, TArray& O){ UDirectiveUtilEditorActorSubsystem::FilterActorsByTag(S, O, FName("Alpha"), Exclude); }); + TestTrue("ByTag Include => A only", Inc.Contains(ActorA) && !Inc.Contains(ActorB) && !Inc.Contains(ActorC)); + TestTrue("ByTag Exclude => B and C", !Exc.Contains(ActorA) && Exc.Contains(ActorB) && Exc.Contains(ActorC)); + } + + // Static mesh by reference + by name. + { + const TArray Inc = RunFilter([&](const TArray& S, TArray& O){ UDirectiveUtilEditorActorSubsystem::FilterActorsByStaticMesh(S, O, Cube, Include); }); + TestTrue("ByStaticMesh(Cube) => A only", Inc.Contains(ActorA) && !Inc.Contains(ActorB) && !Inc.Contains(ActorC)); + const TArray ByName = RunFilter([](const TArray& S, TArray& O){ UDirectiveUtilEditorActorSubsystem::FilterActorsByStaticMeshName(S, O, TEXT("Cube"), Include); }); + TestTrue("ByStaticMeshName(Cube) => A", ByName.Contains(ActorA) && !ByName.Contains(ActorB)); + } + + // Material by reference + by name (override slot). + { + const TArray Inc = RunFilter([&](const TArray& S, TArray& O){ UDirectiveUtilEditorActorSubsystem::FilterActorsByMaterial(S, O, MatA, OverrideOnly, Include); }); + const TArray Exc = RunFilter([&](const TArray& S, TArray& O){ UDirectiveUtilEditorActorSubsystem::FilterActorsByMaterial(S, O, MatA, OverrideOnly, Exclude); }); + TestTrue("ByMaterial(MatA) Include => A only", Inc.Contains(ActorA) && !Inc.Contains(ActorB)); + TestTrue("ByMaterial(MatA) Exclude => B and C, not A", !Exc.Contains(ActorA) && Exc.Contains(ActorB) && Exc.Contains(ActorC)); + const TArray ByName = RunFilter([&](const TArray& S, TArray& O){ UDirectiveUtilEditorActorSubsystem::FilterActorsByMaterialName(S, O, MatA->GetName(), OverrideOnly, Include); }); + TestTrue("ByMaterialName(MatA) => A", ByName.Contains(ActorA) && !ByName.Contains(ActorB)); + } + + // Vert / tri count: query the cube's actual counts, then assert in-range includes and out-of-range excludes. + { + const int32 Verts = Cube->GetNumVertices(0); + TestTrue("ByVertCount [V,V] => A", RunFilter([Verts](const TArray& S, TArray& O){ UDirectiveUtilEditorActorSubsystem::FilterActorsByVertCount(S, O, Verts, Verts, Include); }).Contains(ActorA)); + TestFalse("ByVertCount out-of-range => not A", RunFilter([Verts](const TArray& S, TArray& O){ UDirectiveUtilEditorActorSubsystem::FilterActorsByVertCount(S, O, Verts + 100000, Verts + 200000, Include); }).Contains(ActorA)); + const int32 Tris = Cube->GetNumTriangles(0); + TestTrue("ByTriCount [T,T] => A", RunFilter([Tris](const TArray& S, TArray& O){ UDirectiveUtilEditorActorSubsystem::FilterActorsByTriCount(S, O, Tris, Tris, Include); }).Contains(ActorA)); + } + + // Mobility. + { + TestTrue("ByMobility(Static) => A, not B", RunFilter([](const TArray& S, TArray& O){ UDirectiveUtilEditorActorSubsystem::FilterActorsByMobility(S, O, EComponentMobility::Static, Include); }).Contains(ActorA)); + TestTrue("ByMobility(Movable) => B, not A", RunFilter([&](const TArray& S, TArray& O){ UDirectiveUtilEditorActorSubsystem::FilterActorsByMobility(S, O, EComponentMobility::Movable, Include); }).Contains(ActorB)); + } + + // Collision (BlockAll on A implies WorldStatic object type, QueryAndPhysics, blocking responses). + { + TestTrue("ByCollisionProfile(BlockAll) => A", RunFilter([](const TArray& S, TArray& O){ UDirectiveUtilEditorActorSubsystem::FilterActorsByCollisionProfile(S, O, FName("BlockAll"), Include); }).Contains(ActorA)); + TestTrue("ByCollisionChannel(WorldStatic) => A", RunFilter([](const TArray& S, TArray& O){ UDirectiveUtilEditorActorSubsystem::FilterActorsByCollisionChannel(S, O, ECC_WorldStatic, Include); }).Contains(ActorA)); + TestTrue("ByCollisionEnabled(QueryAndPhysics) => A", RunFilter([](const TArray& S, TArray& O){ UDirectiveUtilEditorActorSubsystem::FilterActorsByCollisionEnabled(S, O, ECollisionEnabled::QueryAndPhysics, Include); }).Contains(ActorA)); + TestTrue("ByCollisionResponse(WorldDynamic, Block) => A", RunFilter([](const TArray& S, TArray& O){ UDirectiveUtilEditorActorSubsystem::FilterActorsByCollisionResponse(S, O, ECC_WorldDynamic, ECR_Block, Include); }).Contains(ActorA)); + } + + // Nanite: assert the filter partitions correctly (A matched by exactly one of true/false) without + // reading the deprecated member directly. + { + const bool bInFalse = RunFilter([](const TArray& S, TArray& O){ UDirectiveUtilEditorActorSubsystem::FilterActorsByNaniteState(S, O, false, Include); }).Contains(ActorA); + const bool bInTrue = RunFilter([](const TArray& S, TArray& O){ UDirectiveUtilEditorActorSubsystem::FilterActorsByNaniteState(S, O, true, Include); }).Contains(ActorA); + TestTrue("ByNaniteState partitions A into exactly one of true/false", bInFalse != bInTrue); + } + + // LOD count: query the cube's LOD count and assert in-range includes A. + { + const int32 LODs = Cube->GetNumLODs(); + TestTrue("ByLODCount [n,n] => A", RunFilter([LODs](const TArray& S, TArray& O){ UDirectiveUtilEditorActorSubsystem::FilterActorsByLODCount(S, O, LODs, LODs, Include); }).Contains(ActorA)); + } + + // Actor bounds: query A's own size and assert a range around it includes A. + { + FVector Origin, Extent; + ActorA->GetActorBounds(false, Origin, Extent); + const FVector Size = Extent * 2.0f; + TestTrue("ByBounds around A's size => A", RunFilter([&](const TArray& S, TArray& O){ UDirectiveUtilEditorActorSubsystem::FilterActorsByBounds(S, O, Size - FVector(1.0f), Size + FVector(1.0f), Include); }).Contains(ActorA)); + } + + // World location: A at origin, B far away. + { + const TArray Near = RunFilter([](const TArray& S, TArray& O){ UDirectiveUtilEditorActorSubsystem::FilterActorsByWorldLocation(S, O, FVector::ZeroVector, 50.0f, Include); }); + TestTrue("ByWorldLocation near origin => A, not the far B", Near.Contains(ActorA) && !Near.Contains(ActorB)); + } + + // Texture by name: a name no material uses -> Include matches none, Exclude matches all (exercises traversal). + { + TestEqual("ByTextureName(absent) Include => none", RunFilter([](const TArray& S, TArray& O){ UDirectiveUtilEditorActorSubsystem::FilterActorsByTextureName(S, O, TEXT("__udcore_absent_texture__"), BaseAndOverride, Include); }).Num(), 0); + TestEqual("ByTextureName(absent) Exclude => all", RunFilter([](const TArray& S, TArray& O){ UDirectiveUtilEditorActorSubsystem::FilterActorsByTextureName(S, O, TEXT("__udcore_absent_texture__"), BaseAndOverride, Exclude); }).Num(), 3); + } + + GEngine->DestroyWorldContext(World); + World->DestroyWorld(false); + + return true; +} + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilEditorActorSubsystemEmptyActorsTest, "DirectiveUtilities.EditorActorSubsystemEmptyActorsTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter) + +bool FDirectiveUtilEditorActorSubsystemEmptyActorsTest::RunTest(const FString& Parameters) +{ + UWorld* World = UWorld::CreateWorld(EWorldType::Editor, false); + if (!World) + { + AddError(TEXT("Failed to create a transient world for the empty actors test.")); + return false; + } + FWorldContext& WorldContext = GEngine->CreateNewWorldContext(EWorldType::Editor); + WorldContext.SetCurrentWorld(World); + + // A bare actor whose only component is a childless scene root: the "Empty Actor" shape. + AActor* BareActor = World->SpawnActor(); + USceneComponent* BareRoot = NewObject(BareActor); + BareActor->SetRootComponent(BareRoot); + BareRoot->RegisterComponent(); + + AStaticMeshActor* MeshActor = World->SpawnActor(); + + const TArray Source = { BareActor, MeshActor }; + + TArray Included; + UDirectiveUtilEditorActorSubsystem::FilterEmptyActors(Source, Included, Include); + TestTrue("Include: bare actor with only a scene root is empty", Included.Contains(BareActor)); + TestFalse("Include: static mesh actor is not empty", Included.Contains(MeshActor)); + TestEqual("Include: only the bare actor is returned", Included.Num(), 1); + + TArray Excluded; + UDirectiveUtilEditorActorSubsystem::FilterEmptyActors(Source, Excluded, Exclude); + TestFalse("Exclude: bare actor is not returned", Excluded.Contains(BareActor)); + TestTrue("Exclude: static mesh actor is returned", Excluded.Contains(MeshActor)); + TestEqual("Exclude: only the static mesh actor is returned", Excluded.Num(), 1); + + // Aliasing: filtering an array into itself rebuilds it in place. + TArray Aliased = { BareActor, MeshActor }; + UDirectiveUtilEditorActorSubsystem::FilterEmptyActors(Aliased, Aliased, Include); + TestEqual("Aliased: array is rebuilt in place with one entry", Aliased.Num(), 1); + TestTrue("Aliased: only the bare actor remains", Aliased.Contains(BareActor)); + + GEngine->DestroyWorldContext(World); + World->DestroyWorld(false); + + return true; +} + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilEditorActorSubsystemQueryAlignmentTest, "DirectiveUtilities.EditorActorSubsystemQueryAlignmentTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter) + +bool FDirectiveUtilEditorActorSubsystemQueryAlignmentTest::RunTest(const FString& Parameters) +{ + UStaticMesh* Cube = LoadObject(nullptr, TEXT("/Engine/BasicShapes/Cube.Cube")); + if (!Cube) + { + AddInfo(TEXT("Engine basic shapes unavailable; skipping query alignment test.")); + return true; + } + + // The GetActorsBy* queries read from the editor world rather than a passed array. + UWorld* EditorWorld = nullptr; + for (const FWorldContext& Context : GEngine->GetWorldContexts()) + { + if (Context.WorldType == EWorldType::Editor && Context.World()) + { + EditorWorld = Context.World(); + break; + } + } + if (!EditorWorld) + { + AddInfo(TEXT("No editor world available; skipping query alignment test.")); + return true; + } + + AActor* MeshActor = EditorWorld->SpawnActor(); + USceneComponent* Root = NewObject(MeshActor); + Root->SetMobility(EComponentMobility::Movable); + MeshActor->SetRootComponent(Root); + Root->RegisterComponent(); + UStaticMeshComponent* MeshComponent = NewObject(MeshActor); + MeshComponent->SetMobility(EComponentMobility::Movable); + MeshComponent->SetupAttachment(Root); + MeshComponent->SetStaticMesh(Cube); + MeshComponent->RegisterComponent(); + MeshActor->AddInstanceComponent(MeshComponent); + MeshActor->SetActorLocation(FVector::ZeroVector); + + // The query methods keep no instance state, so a transient instance is enough headless. + UDirectiveUtilEditorActorSubsystem* Subsystem = NewObject(); + + // Bounding box: an enclosing box finds the actor, a disjoint one does not. + TArray InBox; + Subsystem->GetActorsByBoundingBox(InBox, FVector(-100000.0f), FVector(100000.0f), World, Include); + TestTrue("BoundingBox: enclosing box finds the actor", InBox.Contains(MeshActor)); + TArray OutOfBox; + Subsystem->GetActorsByBoundingBox(OutOfBox, FVector(900000.0f), FVector(900100.0f), World, Include); + TestFalse("BoundingBox: disjoint box does not find the actor", OutOfBox.Contains(MeshActor)); + + // Mesh name: a lowercase substring matches case-insensitively, as in the Filter variant. + TArray ByName; + Subsystem->GetActorsByStaticMeshName(ByName, TEXT("cub"), World, Include); + TestTrue("StaticMeshName: lowercase substring finds the actor", ByName.Contains(MeshActor)); + + // Mobility: the root component's mobility is what counts. + const TArray Source = { MeshActor }; + TArray MovableActors; + UDirectiveUtilEditorActorSubsystem::FilterActorsByMobility(Source, MovableActors, EComponentMobility::Movable, Include); + TestTrue("Mobility: movable root is matched", MovableActors.Contains(MeshActor)); + TArray StaticActors; + UDirectiveUtilEditorActorSubsystem::FilterActorsByMobility(Source, StaticActors, EComponentMobility::Static, Include); + TestFalse("Mobility: static does not match a movable root", StaticActors.Contains(MeshActor)); + + EditorWorld->DestroyActor(MeshActor); + + return true; +} + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilEditorActorSubsystemBoundsTest, "DirectiveUtilities.EditorActorSubsystemBoundsTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter) + +bool FDirectiveUtilEditorActorSubsystemBoundsTest::RunTest(const FString& Parameters) +{ + UWorld* World = UWorld::CreateWorld(EWorldType::Editor, false); + if (!World) + { + AddError(TEXT("Failed to create a transient world for the bounds test.")); + return false; + } + FWorldContext& WorldContext = GEngine->CreateNewWorldContext(EWorldType::Editor); + WorldContext.SetCurrentWorld(World); + + auto SpawnPoint = [World](const FVector& Location, const FVector& Scale) -> AActor* + { + AActor* Actor = World->SpawnActor(); + USceneComponent* Root = NewObject(Actor); + Actor->SetRootComponent(Root); + Root->RegisterComponent(); + Actor->SetActorScale3D(Scale); + Actor->SetActorLocation(Location); + return Actor; + }; + + // Axis-aligned box of extent 100 at the origin. + AActor* BoxActor = World->SpawnActor(); + USceneComponent* BoxRoot = NewObject(BoxActor); + BoxActor->SetRootComponent(BoxRoot); + BoxRoot->RegisterComponent(); + UBoxComponent* Box = NewObject(BoxActor); + Box->SetupAttachment(BoxRoot); + Box->RegisterComponent(); + Box->SetBoxExtent(FVector(100.0f, 100.0f, 100.0f)); + + // Point well inside the box, on an actor scaled to 0.1. The previous code multiplied the box + // extent by the queried actor's scale and would wrongly report this as outside. + TestTrue("Box: point inside is detected regardless of the queried actor's scale", + UDirectiveUtilEditorActorSubsystem::IsActorWithinBoxBounds(SpawnPoint(FVector(50.0f, 50.0f, 50.0f), FVector(0.1f)), Box)); + TestFalse("Box: point beyond the extent is rejected", + UDirectiveUtilEditorActorSubsystem::IsActorWithinBoxBounds(SpawnPoint(FVector(250.0f, 0.0f, 0.0f), FVector::OneVector), Box)); + + // Capsule of radius 50 and half-height 100 at the origin. + AActor* CapsuleActor = World->SpawnActor(); + USceneComponent* CapsuleRoot = NewObject(CapsuleActor); + CapsuleActor->SetRootComponent(CapsuleRoot); + CapsuleRoot->RegisterComponent(); + UCapsuleComponent* Capsule = NewObject(CapsuleActor); + Capsule->SetupAttachment(CapsuleRoot); + Capsule->RegisterComponent(); + Capsule->SetCapsuleSize(50.0f, 100.0f); + + TestTrue("Capsule: point within the upper cap is detected", + UDirectiveUtilEditorActorSubsystem::IsActorWithinCapsuleBounds(SpawnPoint(FVector(0.0f, 0.0f, 90.0f), FVector::OneVector), Capsule)); + TestTrue("Capsule: point within the radius is detected", + UDirectiveUtilEditorActorSubsystem::IsActorWithinCapsuleBounds(SpawnPoint(FVector(40.0f, 0.0f, 0.0f), FVector::OneVector), Capsule)); + TestFalse("Capsule: point beyond the radius is rejected (not a broad sphere)", + UDirectiveUtilEditorActorSubsystem::IsActorWithinCapsuleBounds(SpawnPoint(FVector(60.0f, 0.0f, 0.0f), FVector::OneVector), Capsule)); + TestFalse("Capsule: point beyond the end cap is rejected", + UDirectiveUtilEditorActorSubsystem::IsActorWithinCapsuleBounds(SpawnPoint(FVector(0.0f, 0.0f, 200.0f), FVector::OneVector), Capsule)); + + GEngine->DestroyWorldContext(World); + World->DestroyWorld(false); + + return true; +} + +#endif diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilEditorAssetLibraryTest.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilEditorAssetLibraryTest.cpp new file mode 100644 index 00000000..722cf2d0 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilEditorAssetLibraryTest.cpp @@ -0,0 +1,51 @@ +#if WITH_EDITOR + +#include "Libraries/DirectiveUtilEditorAssetLibrary.h" +#include "Engine/World.h" +#include "Misc/AutomationTest.h" + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilEditorAssetLibraryTest, "DirectiveUtilities.EditorAssetLibraryTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter) + +bool FDirectiveUtilEditorAssetLibraryTest::RunTest(const FString& Parameters) +{ + EDirectiveUtilSuccessStatus Status = EDirectiveUtilSuccessStatus::Success; + + const FString DefaultName = UDirectiveUtilEditorAssetLibrary::GetDefaultAssetNameForClass(nullptr, Status); + TestEqual("GetDefaultAssetNameForClass should fail for a null class", Status, EDirectiveUtilSuccessStatus::Failure); + TestTrue("GetDefaultAssetNameForClass should return an empty name for a null class", DefaultName.IsEmpty()); + + Status = EDirectiveUtilSuccessStatus::Success; + const TArray NoAssets = UDirectiveUtilEditorAssetLibrary::GetAssetsByClass(nullptr, TEXT("/Game"), false, true, Status); + TestEqual("GetAssetsByClass should fail for a null class", Status, EDirectiveUtilSuccessStatus::Failure); + TestEqual("GetAssetsByClass should return no assets for a null class", NoAssets.Num(), 0); + + Status = EDirectiveUtilSuccessStatus::Success; + const TArray NoDependencies = UDirectiveUtilEditorAssetLibrary::GetAssetDependencies(FAssetData(), false, Status); + TestEqual("GetAssetDependencies should fail for an invalid asset", Status, EDirectiveUtilSuccessStatus::Failure); + TestEqual("GetAssetDependencies should return nothing for an invalid asset", NoDependencies.Num(), 0); + + Status = EDirectiveUtilSuccessStatus::Success; + const TArray NoReferencers = UDirectiveUtilEditorAssetLibrary::GetAssetReferencers(FAssetData(), false, Status); + TestEqual("GetAssetReferencers should fail for an invalid asset", Status, EDirectiveUtilSuccessStatus::Failure); + TestEqual("GetAssetReferencers should return nothing for an invalid asset", NoReferencers.Num(), 0); + + Status = EDirectiveUtilSuccessStatus::Failure; + UDirectiveUtilEditorAssetLibrary::GetAssetsByClass(UWorld::StaticClass(), TEXT("/Game"), false, true, Status); + TestEqual("GetAssetsByClass should succeed for a valid class query", Status, EDirectiveUtilSuccessStatus::Success); + + const TMap DuplicateAssets = + UDirectiveUtilEditorAssetLibrary::FindDuplicateAssets({TEXT("/Engine/BasicShapes"), TEXT("/Engine/BasicShapes")}, false); + for (const TPair& Pair : DuplicateAssets) + { + TSet UniquePaths; + for (const FString& AssetPath : Pair.Value.DuplicateAssetPaths) + { + UniquePaths.Add(AssetPath); + } + TestEqual("FindDuplicateAssets should return each asset path once", UniquePaths.Num(), Pair.Value.DuplicateAssetPaths.Num()); + } + + return true; +} + +#endif diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilFunctionLibraryTest.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilFunctionLibraryTest.cpp new file mode 100644 index 00000000..892a6c4a --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilFunctionLibraryTest.cpp @@ -0,0 +1,97 @@ +#include "Libraries/DirectiveUtilFunctionLibrary.h" +#include "Tests/AutomationCommon.h" +#include "Misc/AutomationTest.h" + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilFunctionLibraryTest, "DirectiveUtilities.FunctionLibraryTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter) + +bool FDirectiveUtilFunctionLibraryTest::RunTest(const FString& Parameters) +{ + // Preserve the user/CI clipboard so this test doesn't destroy it, and restore it before returning. + const FString OriginalClipboard = UDirectiveUtilFunctionLibrary::GetStringFromClipboard(); + + const FText TestText = FText::FromString(TEXT("Hello, Clipboard!")); + UDirectiveUtilFunctionLibrary::CopyTextToClipboard(TestText); + const FText ClipboardText = UDirectiveUtilFunctionLibrary::GetTextFromClipboard(); + TestEqual("GetTextFromClipboard should return the copied text", ClipboardText.ToString(), TestText.ToString()); + + ADD_LATENT_AUTOMATION_COMMAND(FEngineWaitLatentCommand(1.0f)); + + const FString TestString = TEXT("Hello, Clipboard!"); + UDirectiveUtilFunctionLibrary::CopyStringToClipboard(TestString); + const FString ClipboardString = UDirectiveUtilFunctionLibrary::GetStringFromClipboard(); + TestEqual("GetStringFromClipboard should return the copied string", ClipboardString, TestString); + + ADD_LATENT_AUTOMATION_COMMAND(FEngineWaitLatentCommand(1.0f)); + + UDirectiveUtilFunctionLibrary::ClearClipboard(); + const FString ClearedClipboardString = UDirectiveUtilFunctionLibrary::GetStringFromClipboard(); + TestEqual("GetStringFromClipboard should return an empty string after clearing the clipboard", ClearedClipboardString, TEXT("")); + + const FString ProjectVersion = UDirectiveUtilFunctionLibrary::GetProjectVersion(); + TestNotEqual("GetProjectVersion should return a non-empty string", ProjectVersion, FString("")); + + TestTrue("IsRunningInEditor should return true in editor context", + UDirectiveUtilFunctionLibrary::IsRunningInEditor()); + + // Pure pass-through to the engine's GetDerivedClasses (which appends to the output array). + + // Happy path (recursive): the running module guarantees a stable hierarchy to query. + TArray RecursiveDerived; + UDirectiveUtilFunctionLibrary::GetChildClasses(UBlueprintFunctionLibrary::StaticClass(), true, RecursiveDerived); + TestTrue("GetChildClasses should return a non-empty list for a base class with subclasses", + RecursiveDerived.Num() > 0); + TestTrue("GetChildClasses should include a known derived class (UDirectiveUtilFunctionLibrary)", + RecursiveDerived.Contains(UDirectiveUtilFunctionLibrary::StaticClass())); + TestFalse("GetChildClasses should not include the base class itself", + RecursiveDerived.Contains(UBlueprintFunctionLibrary::StaticClass())); + + // Non-recursive results must not exceed the recursive results for the same base. + TArray NonRecursiveDerived; + UDirectiveUtilFunctionLibrary::GetChildClasses(UBlueprintFunctionLibrary::StaticClass(), false, NonRecursiveDerived); + TestTrue("GetChildClasses non-recursive count should not exceed recursive count", + NonRecursiveDerived.Num() <= RecursiveDerived.Num()); + + // Leaf class with no subclasses: an empty input array should remain empty. + TArray LeafDerived; + UDirectiveUtilFunctionLibrary::GetChildClasses(UDirectiveUtilFunctionLibrary::StaticClass(), true, LeafDerived); + TestEqual("GetChildClasses should return an empty list for a class with no subclasses", + LeafDerived.Num(), 0); + + // Null base class: should not crash and should add nothing. + TArray NullBaseDerived; + UDirectiveUtilFunctionLibrary::GetChildClasses(nullptr, true, NullBaseDerived); + TestEqual("GetChildClasses should return an empty list for a null base class", + NullBaseDerived.Num(), 0); + + { + const TCHAR* CommandLine = TEXT("-Fast -Mode=Quality -Name=\"Big Save\""); + + TestTrue("HasCommandLineSwitch should find a present switch", + UDirectiveUtilFunctionLibrary::HasCommandLineSwitch(CommandLine, TEXT("Fast"))); + TestTrue("HasCommandLineSwitch should match case-insensitively", + UDirectiveUtilFunctionLibrary::HasCommandLineSwitch(CommandLine, TEXT("fast"))); + TestFalse("HasCommandLineSwitch should not find an absent switch", + UDirectiveUtilFunctionLibrary::HasCommandLineSwitch(CommandLine, TEXT("Slow"))); + TestFalse("HasCommandLineSwitch should return false for an empty switch", + UDirectiveUtilFunctionLibrary::HasCommandLineSwitch(CommandLine, TEXT(""))); + + FString Value; + TestTrue("GetCommandLineOption should find a present key", + UDirectiveUtilFunctionLibrary::GetCommandLineOption(CommandLine, TEXT("Mode"), Value)); + TestEqual("GetCommandLineOption should return the key's value", Value, FString(TEXT("Quality"))); + TestTrue("GetCommandLineOption should read a quoted value", + UDirectiveUtilFunctionLibrary::GetCommandLineOption(CommandLine, TEXT("Name"), Value)); + TestEqual("GetCommandLineOption should return the quoted value without quotes", Value, FString(TEXT("Big Save"))); + TestFalse("GetCommandLineOption should not find an absent key", + UDirectiveUtilFunctionLibrary::GetCommandLineOption(CommandLine, TEXT("Missing"), Value)); + TestFalse("GetCommandLineOption should return false for an empty key", + UDirectiveUtilFunctionLibrary::GetCommandLineOption(CommandLine, TEXT(""), Value)); + + // Smoke test through the process-command-line path. + TestFalse("HasCommandLineSwitch should not find a switch that was never passed", + UDirectiveUtilFunctionLibrary::HasCommandLineSwitch(TEXT("DirectiveUtilitiesDefinitelyNotPassed"))); + } + + UDirectiveUtilFunctionLibrary::CopyStringToClipboard(OriginalClipboard); + return true; +} diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilGameplayTagFunctionLibraryTest.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilGameplayTagFunctionLibraryTest.cpp new file mode 100644 index 00000000..24e40e3d --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilGameplayTagFunctionLibraryTest.cpp @@ -0,0 +1,113 @@ +#include "Libraries/DirectiveUtilGameplayTagFunctionLibrary.h" +#include "GameplayTagsManager.h" +#include "Misc/AutomationTest.h" +#include "Misc/FileHelper.h" +#include "Misc/Paths.h" + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilGameplayTagFunctionLibraryTest, "DirectiveUtilities.GameplayTagFunctionLibraryTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter) + +bool FDirectiveUtilGameplayTagFunctionLibraryTest::RunTest(const FString& Parameters) +{ + // Native tag registration is unavailable to a DeveloperTool module (module-type validation) + // and the legacy FName path ensures on 5.6/5.7 after startup. Register through a runtime + // ini tag source instead, which supports late addition from any module. + const FName TestTagName(TEXT("DirectiveUtilities.Test.Alpha.Beta")); + const FName SiblingTagName(TEXT("DirectiveUtilities.Test.Alpha.Gamma")); + const FName GrandchildTagName(TEXT("DirectiveUtilities.Test.Alpha.Beta.Delta")); + FGameplayTag Tag = FGameplayTag::RequestGameplayTag(TestTagName, false); + FGameplayTag SiblingTag = FGameplayTag::RequestGameplayTag(SiblingTagName, false); + FGameplayTag GrandchildTag = FGameplayTag::RequestGameplayTag(GrandchildTagName, false); + if (!Tag.IsValid() || !SiblingTag.IsValid() || !GrandchildTag.IsValid()) + { + // AddTagIniSearchPath will not rescan an already-added directory, so growing the + // tag set requires a fresh directory name. + const FString TagIniDirectory = FPaths::ProjectSavedDir() / TEXT("DirectiveUtilitiesTests") / TEXT("Tags"); + const FString TagIniPath = TagIniDirectory / TEXT("DirectiveUtilitiesTestTags.ini"); + const FString TagIniContents = TEXT("[/Script/GameplayTags.GameplayTagsList]\n") + TEXT("GameplayTagList=(Tag=\"DirectiveUtilities.Test.Alpha.Beta\",DevComment=\"DirectiveUtilities automation test tag\")\n") + TEXT("GameplayTagList=(Tag=\"DirectiveUtilities.Test.Alpha.Gamma\",DevComment=\"DirectiveUtilities automation test tag\")\n") + TEXT("GameplayTagList=(Tag=\"DirectiveUtilities.Test.Alpha.Beta.Delta\",DevComment=\"DirectiveUtilities automation test tag\")\n"); + if (!FFileHelper::SaveStringToFile(TagIniContents, *TagIniPath)) + { + AddError(TEXT("Failed to write the test tag ini file.")); + return false; + } + UGameplayTagsManager::Get().AddTagIniSearchPath(TagIniDirectory); + Tag = FGameplayTag::RequestGameplayTag(TestTagName, false); + SiblingTag = FGameplayTag::RequestGameplayTag(SiblingTagName, false); + GrandchildTag = FGameplayTag::RequestGameplayTag(GrandchildTagName, false); + } + TestTrue("The registered test tag should be valid", Tag.IsValid()); + TestTrue("The registered sibling test tag should be valid", SiblingTag.IsValid()); + TestTrue("The registered grandchild test tag should be valid", GrandchildTag.IsValid()); + + TestEqual("GetTagSegments should return each segment in order", + UDirectiveUtilGameplayTagFunctionLibrary::GetTagSegments(Tag), + TArray({TEXT("DirectiveUtilities"), TEXT("Test"), TEXT("Alpha"), TEXT("Beta")})); + TestEqual("GetTagDepth should return the segment count", UDirectiveUtilGameplayTagFunctionLibrary::GetTagDepth(Tag), 4); + TestEqual("GetTagLeafName should return the leaf segment", UDirectiveUtilGameplayTagFunctionLibrary::GetTagLeafName(Tag), FString(TEXT("Beta"))); + + const FGameplayTag DirectParent = UDirectiveUtilGameplayTagFunctionLibrary::GetTagDirectParent(Tag); + TestEqual("GetTagDirectParent should return the immediate parent", DirectParent.GetTagName(), FName(TEXT("DirectiveUtilities.Test.Alpha"))); + + const FGameplayTagContainer Parents = UDirectiveUtilGameplayTagFunctionLibrary::GetTagParents(Tag); + TestTrue("GetTagParents should contain the direct parent", Parents.HasTagExact(DirectParent)); + TestFalse("GetTagParents should exclude the tag itself", Parents.HasTagExact(Tag)); + + const FGameplayTag InvalidTag; + TestFalse("GetTagDirectParent of an invalid tag should be invalid", UDirectiveUtilGameplayTagFunctionLibrary::GetTagDirectParent(InvalidTag).IsValid()); + TestEqual("GetTagDepth of an invalid tag should be 0", UDirectiveUtilGameplayTagFunctionLibrary::GetTagDepth(InvalidTag), 0); + TestTrue("GetTagLeafName of an invalid tag should be empty", UDirectiveUtilGameplayTagFunctionLibrary::GetTagLeafName(InvalidTag).IsEmpty()); + TestEqual("GetTagSegments of an invalid tag should be empty", UDirectiveUtilGameplayTagFunctionLibrary::GetTagSegments(InvalidTag).Num(), 0); + + const FGameplayTag AlphaTag = FGameplayTag::RequestGameplayTag(FName(TEXT("DirectiveUtilities.Test.Alpha")), false); + TestTrue("The auto-registered parent tag should be valid", AlphaTag.IsValid()); + + const FGameplayTagContainer Children = UDirectiveUtilGameplayTagFunctionLibrary::GetTagChildren(AlphaTag); + TestTrue("GetTagChildren should contain the first direct child", Children.HasTagExact(Tag)); + TestTrue("GetTagChildren should contain the second direct child", Children.HasTagExact(SiblingTag)); + TestTrue("GetTagChildren should contain a grandchild", Children.HasTagExact(GrandchildTag)); + + const FGameplayTagContainer DirectChildren = UDirectiveUtilGameplayTagFunctionLibrary::GetTagDirectChildren(AlphaTag); + TestTrue("GetTagDirectChildren should contain the first direct child", DirectChildren.HasTagExact(Tag)); + TestTrue("GetTagDirectChildren should contain the second direct child", DirectChildren.HasTagExact(SiblingTag)); + TestFalse("GetTagDirectChildren should exclude grandchildren", DirectChildren.HasTagExact(GrandchildTag)); + + TestTrue("GetTagChildren of a leaf tag should be empty", UDirectiveUtilGameplayTagFunctionLibrary::GetTagChildren(GrandchildTag).IsEmpty()); + + TestEqual("GetTagCommonAncestor should return the deepest shared ancestor", + UDirectiveUtilGameplayTagFunctionLibrary::GetTagCommonAncestor(GrandchildTag, SiblingTag).GetTagName(), FName(TEXT("DirectiveUtilities.Test.Alpha"))); + TestEqual("GetTagCommonAncestor may return one of the inputs", + UDirectiveUtilGameplayTagFunctionLibrary::GetTagCommonAncestor(Tag, GrandchildTag).GetTagName(), Tag.GetTagName()); + + TestTrue("GetTagChildren of an invalid tag should be empty", UDirectiveUtilGameplayTagFunctionLibrary::GetTagChildren(InvalidTag).IsEmpty()); + TestTrue("GetTagDirectChildren of an invalid tag should be empty", UDirectiveUtilGameplayTagFunctionLibrary::GetTagDirectChildren(InvalidTag).IsEmpty()); + TestFalse("GetTagCommonAncestor of an invalid tag should be invalid", UDirectiveUtilGameplayTagFunctionLibrary::GetTagCommonAncestor(InvalidTag, Tag).IsValid()); + + TestEqual("GetTagAtDepth should truncate to the requested depth", + UDirectiveUtilGameplayTagFunctionLibrary::GetTagAtDepth(GrandchildTag, 3).GetTagName(), FName(TEXT("DirectiveUtilities.Test.Alpha"))); + TestEqual("GetTagAtDepth at the tag's own depth should return the tag", + UDirectiveUtilGameplayTagFunctionLibrary::GetTagAtDepth(GrandchildTag, 5).GetTagName(), GrandchildTag.GetTagName()); + TestEqual("GetTagAtDepth beyond the tag's depth should return the tag", + UDirectiveUtilGameplayTagFunctionLibrary::GetTagAtDepth(GrandchildTag, 99).GetTagName(), GrandchildTag.GetTagName()); + TestFalse("GetTagAtDepth of zero should be invalid", UDirectiveUtilGameplayTagFunctionLibrary::GetTagAtDepth(GrandchildTag, 0).IsValid()); + TestFalse("GetTagAtDepth of an invalid tag should be invalid", UDirectiveUtilGameplayTagFunctionLibrary::GetTagAtDepth(InvalidTag, 2).IsValid()); + + const FGameplayTagContainer Siblings = UDirectiveUtilGameplayTagFunctionLibrary::GetTagSiblings(Tag); + TestTrue("GetTagSiblings should contain the sibling", Siblings.HasTagExact(SiblingTag)); + TestFalse("GetTagSiblings should exclude the tag itself", Siblings.HasTagExact(Tag)); + TestFalse("GetTagSiblings should exclude the tag's children", Siblings.HasTagExact(GrandchildTag)); + TestTrue("GetTagSiblings of an invalid tag should be empty", UDirectiveUtilGameplayTagFunctionLibrary::GetTagSiblings(InvalidTag).IsEmpty()); + + TestTrue("IsLeafTag should be true for a childless tag", UDirectiveUtilGameplayTagFunctionLibrary::IsLeafTag(GrandchildTag)); + TestFalse("IsLeafTag should be false for a tag with children", UDirectiveUtilGameplayTagFunctionLibrary::IsLeafTag(Tag)); + TestFalse("IsLeafTag of an invalid tag should be false", UDirectiveUtilGameplayTagFunctionLibrary::IsLeafTag(InvalidTag)); + + const TArray FoundTags = UDirectiveUtilGameplayTagFunctionLibrary::FindRegisteredTags(TEXT("test.alpha")); + TestTrue("FindRegisteredTags should match case-insensitively", FoundTags.Contains(Tag)); + TestTrue("FindRegisteredTags should find every matching tag", FoundTags.Contains(SiblingTag) && FoundTags.Contains(GrandchildTag)); + TestEqual("FindRegisteredTags with no match should be empty", UDirectiveUtilGameplayTagFunctionLibrary::FindRegisteredTags(TEXT("DirectiveUtilitiesNoSuchTagXYZ")).Num(), 0); + TestEqual("FindRegisteredTags with an empty substring should be empty", UDirectiveUtilGameplayTagFunctionLibrary::FindRegisteredTags(TEXT("")).Num(), 0); + + return true; +} diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilInputActivePathTest.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilInputActivePathTest.cpp new file mode 100644 index 00000000..2d7634e1 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilInputActivePathTest.cpp @@ -0,0 +1,146 @@ +// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License. + +#include "Libraries/DirectiveUtilInputFunctionLibrary.h" +#include "Types/DirectiveUtilInputTypes.h" +#include "Types/DirectiveUtilTypes.h" +#include "InputMappingContext.h" +#include "EnhancedInputSubsystems.h" +#include "Engine/GameInstance.h" +#include "Engine/LocalPlayer.h" +#include "Engine/World.h" +#include "Engine/Engine.h" +#include "GameFramework/PlayerController.h" +#include "Misc/AutomationTest.h" + +#if WITH_EDITOR + +/** + * Exercises the Enhanced Input library's active paths against a real EnhancedInput subsystem, + * which requires a live local player. A standalone game instance + local player is built for this; + * if that cannot be created in the headless harness the test skips its assertions rather than failing. + */ +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilInputActivePathTest, "DirectiveUtilities.InputActivePathTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter) + +bool FDirectiveUtilInputActivePathTest::RunTest(const FString& Parameters) +{ + // These warnings only fire on the graceful-skip paths; allow (but never require) them. + AddExpectedMessagePlain(TEXT("LocalPlayer not found. Cannot set input mapping contexts."), ELogVerbosity::Warning, EAutomationExpectedMessageFlags::Contains, -1); + AddExpectedMessagePlain(TEXT("EnhancedInput subsystem not found."), ELogVerbosity::Warning, EAutomationExpectedMessageFlags::Contains, -1); + + UGameInstance* GameInstance = NewObject(GEngine); + if (!GameInstance) + { + AddInfo(TEXT("Could not create a game instance; skipping Enhanced Input active-path assertions.")); + return true; + } + GameInstance->AddToRoot(); + GameInstance->InitializeStandalone(); + + UWorld* World = GameInstance->GetWorld(); + + // CreateLocalPlayer ensures when there is no game viewport client (headless), so build the local + // player directly via AddLocalPlayer, which initializes the local-player subsystems without one. + UClass* LocalPlayerClass = GEngine->LocalPlayerClass ? GEngine->LocalPlayerClass.Get() : ULocalPlayer::StaticClass(); + ULocalPlayer* LocalPlayer = NewObject(GEngine, LocalPlayerClass); + if (World && LocalPlayer) + { + GameInstance->AddLocalPlayer(LocalPlayer, FPlatformMisc::GetPlatformUserForUserIndex(0)); + } + + if (!World || !LocalPlayer) + { + AddInfo(TEXT("Local player unavailable in the headless harness; skipping Enhanced Input active-path assertions.")); + GameInstance->Shutdown(); + GameInstance->RemoveFromRoot(); + return true; + } + + APlayerController* PlayerController = World->SpawnActor(); + if (!PlayerController) + { + AddInfo(TEXT("Could not spawn a player controller; skipping Enhanced Input active-path assertions.")); + GameInstance->Shutdown(); + GameInstance->RemoveFromRoot(); + return true; + } + PlayerController->SetPlayer(LocalPlayer); + + UEnhancedInputLocalPlayerSubsystem* Subsystem = UDirectiveUtilInputFunctionLibrary::GetEnhancedInputSubsystem(PlayerController); + if (!Subsystem) + { + AddInfo(TEXT("Enhanced Input subsystem unavailable for the synthetic local player; skipping active-path assertions.")); + GameInstance->Shutdown(); + GameInstance->RemoveFromRoot(); + return true; + } + + // We have a live subsystem: exercise the active add/active/swap/remove/clear paths for real. + TestNotNull("GetEnhancedInputSubsystem returns the subsystem for a live local player", Subsystem); + + UInputMappingContext* ContextA = NewObject(GetTransientPackage()); + UInputMappingContext* ContextB = NewObject(GetTransientPackage()); + ContextA->AddToRoot(); + ContextB->AddToRoot(); + const TSoftObjectPtr SoftA(ContextA); + const TSoftObjectPtr SoftB(ContextB); + + // Add context A. + FDirectiveUtilEnhancedInputContextData DataA; + DataA.InputContext = SoftA; + DataA.Priority = 0; + TArray ToAdd; + ToAdd.Add(DataA); + TestEqual("AddInputMappingContexts succeeds for a live controller", + UDirectiveUtilInputFunctionLibrary::AddInputMappingContexts(PlayerController, ToAdd, false), + EDirectiveUtilSuccessStatus::Success); + TestTrue("Added context is reported active", + UDirectiveUtilInputFunctionLibrary::IsInputMappingContextActive(PlayerController, SoftA)); + + // Swap A -> B. + TestEqual("SwapInputMappingContexts succeeds", + UDirectiveUtilInputFunctionLibrary::SwapInputMappingContexts(PlayerController, SoftA, SoftB, 0, false), + EDirectiveUtilSuccessStatus::Success); + TestFalse("Swapped-out context is inactive", + UDirectiveUtilInputFunctionLibrary::IsInputMappingContextActive(PlayerController, SoftA)); + TestTrue("Swapped-in context is active", + UDirectiveUtilInputFunctionLibrary::IsInputMappingContextActive(PlayerController, SoftB)); + + // Remove B. + TArray> ToRemove; + ToRemove.Add(SoftB); + TestEqual("RemoveInputMappingContexts succeeds", + UDirectiveUtilInputFunctionLibrary::RemoveInputMappingContexts(PlayerController, ToRemove), + EDirectiveUtilSuccessStatus::Success); + TestFalse("Removed context is inactive", + UDirectiveUtilInputFunctionLibrary::IsInputMappingContextActive(PlayerController, SoftB)); + + // Clear all (after re-adding A). + UDirectiveUtilInputFunctionLibrary::AddInputMappingContexts(PlayerController, ToAdd, false); + TestEqual("ClearAllInputMappingContexts succeeds", + UDirectiveUtilInputFunctionLibrary::ClearAllInputMappingContexts(PlayerController), + EDirectiveUtilSuccessStatus::Success); + TestFalse("Context is inactive after clear-all", + UDirectiveUtilInputFunctionLibrary::IsInputMappingContextActive(PlayerController, SoftA)); + + // A clearing add whose every context fails to load must leave the existing mappings untouched. + AddExpectedMessagePlain(TEXT("Input Mapping Contexts failed to load and were not added!"), ELogVerbosity::Warning, EAutomationExpectedMessageFlags::Contains, -1); + UDirectiveUtilInputFunctionLibrary::AddInputMappingContexts(PlayerController, ToAdd, false); + FDirectiveUtilEnhancedInputContextData UnresolvableData; + UnresolvableData.Priority = 0; + TArray UnresolvableToAdd; + UnresolvableToAdd.Add(UnresolvableData); + TestEqual("AddInputMappingContexts fails when no context loads", + UDirectiveUtilInputFunctionLibrary::AddInputMappingContexts(PlayerController, UnresolvableToAdd, true), + EDirectiveUtilSuccessStatus::Failure); + TestTrue("Previously active context survives a failed clearing add", + UDirectiveUtilInputFunctionLibrary::IsInputMappingContextActive(PlayerController, SoftA)); + + ContextA->RemoveFromRoot(); + ContextB->RemoveFromRoot(); + GameInstance->Shutdown(); + GameInstance->RemoveFromRoot(); + + return true; +} + +#endif // WITH_EDITOR diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilInputFunctionLibraryTest.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilInputFunctionLibraryTest.cpp new file mode 100644 index 00000000..1748b111 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilInputFunctionLibraryTest.cpp @@ -0,0 +1,62 @@ +#include "Libraries/DirectiveUtilInputFunctionLibrary.h" +#include "Types/DirectiveUtilTypes.h" +#include "InputMappingContext.h" +#include "Misc/AutomationTest.h" + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilInputFunctionLibraryTest, "DirectiveUtilities.InputFunctionLibraryTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter) + +bool FDirectiveUtilInputFunctionLibraryTest::RunTest(const FString& Parameters) +{ + // The null-controller and invalid-context paths intentionally log warnings. Register them as + // expected (plain match, negative count = consume if present, never required) so the run is clean. + AddExpectedMessagePlain(TEXT("PlayerController is null. Cannot set input mapping contexts."), ELogVerbosity::Warning, EAutomationExpectedMessageFlags::Contains, -1); + AddExpectedMessagePlain(TEXT("Both the previous and new input mapping contexts must be valid."), ELogVerbosity::Warning, EAutomationExpectedMessageFlags::Contains, -1); + + // AddInputMappingContexts should return Failure with null controller + TArray Contexts; + FDirectiveUtilEnhancedInputContextData Context; + Contexts.Add(Context); + TestEqual("AddInputMappingContexts should fail with null controller", + UDirectiveUtilInputFunctionLibrary::AddInputMappingContexts(nullptr, Contexts, false), + EDirectiveUtilSuccessStatus::Failure); + + // RemoveInputMappingContexts should return Failure with null controller + TArray> RemoveContexts; + RemoveContexts.Add(nullptr); + TestEqual("RemoveInputMappingContexts should fail with null controller", + UDirectiveUtilInputFunctionLibrary::RemoveInputMappingContexts(nullptr, RemoveContexts), + EDirectiveUtilSuccessStatus::Failure); + + // AddInputMappingContexts should return Failure with empty contexts + TArray EmptyContexts; + TestEqual("AddInputMappingContexts should fail with empty contexts", + UDirectiveUtilInputFunctionLibrary::AddInputMappingContexts(nullptr, EmptyContexts, false), + EDirectiveUtilSuccessStatus::Failure); + + // RemoveInputMappingContexts should fail with empty contexts (early-return before touching the controller) + TArray> EmptyRemoveContexts; + TestEqual("RemoveInputMappingContexts should fail with empty contexts", + UDirectiveUtilInputFunctionLibrary::RemoveInputMappingContexts(nullptr, EmptyRemoveContexts), + EDirectiveUtilSuccessStatus::Failure); + + // SwapInputMappingContexts loads both contexts before using the controller, so invalid + // (unset) soft pointers must return Failure regardless of the (null) controller. + const TSoftObjectPtr NullContext; + TestEqual("SwapInputMappingContexts should fail when both contexts are invalid", + UDirectiveUtilInputFunctionLibrary::SwapInputMappingContexts(nullptr, NullContext, NullContext, 0, false), + EDirectiveUtilSuccessStatus::Failure); + TestEqual("SwapInputMappingContexts should fail with invalid contexts even when using previous priority", + UDirectiveUtilInputFunctionLibrary::SwapInputMappingContexts(nullptr, NullContext, NullContext, 5, true), + EDirectiveUtilSuccessStatus::Failure); + + // New subsystem-getter helpers: null-controller safety (the active paths require a live player controller). + TestNull("GetEnhancedInputSubsystem should return null for a null controller", + UDirectiveUtilInputFunctionLibrary::GetEnhancedInputSubsystem(nullptr)); + TestFalse("IsInputMappingContextActive should return false for a null controller", + UDirectiveUtilInputFunctionLibrary::IsInputMappingContextActive(nullptr, NullContext)); + TestEqual("ClearAllInputMappingContexts should fail for a null controller", + UDirectiveUtilInputFunctionLibrary::ClearAllInputMappingContexts(nullptr), + EDirectiveUtilSuccessStatus::Failure); + + return true; +} diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilMapFunctionLibraryTest.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilMapFunctionLibraryTest.cpp new file mode 100644 index 00000000..a8e3c5a3 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilMapFunctionLibraryTest.cpp @@ -0,0 +1,148 @@ +#include "Libraries/DirectiveUtilMapFunctionLibrary.h" +#include "Tests/DirectiveUtilTestObject.h" +#include "Misc/AutomationTest.h" + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilMapFunctionLibraryTest, "DirectiveUtilities.MapFunctionLibraryTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter) + +bool FDirectiveUtilMapFunctionLibraryTest::RunTest(const FString& Parameters) +{ + UDirectiveUtilTestObject* TestObject = NewObject(); + + FMapProperty* MapProperty = FindFProperty(UDirectiveUtilTestObject::StaticClass(), GET_MEMBER_NAME_CHECKED(UDirectiveUtilTestObject, TestMap)); + TestNotNull("TestMap property should be found", MapProperty); + if (!MapProperty) + { + return false; + } + + TestObject->TestMap = {{1, 100}, {2, 200}}; + int32 ExistingKey = 2; + int32 FoundValue = -1; + UDirectiveUtilMapFunctionLibrary::GenericMap_FindOrAdd(&TestObject->TestMap, MapProperty, &ExistingKey, &FoundValue); + TestEqual("FindOrAdd should return the existing value for a present key", FoundValue, 200); + TestEqual("FindOrAdd should not change the map size for a present key", TestObject->TestMap.Num(), 2); + + int32 MissingKey = 3; + int32 AddedValue = -1; + UDirectiveUtilMapFunctionLibrary::GenericMap_FindOrAdd(&TestObject->TestMap, MapProperty, &MissingKey, &AddedValue); + TestEqual("FindOrAdd should return the default value for a missing key", AddedValue, 0); + TestEqual("FindOrAdd should grow the map for a missing key", TestObject->TestMap.Num(), 3); + TestTrue("FindOrAdd should insert the missing key", TestObject->TestMap.Contains(3)); + if (const int32* AddedEntry = TestObject->TestMap.Find(3)) + { + TestEqual("FindOrAdd should store a default value for the new key", *AddedEntry, 0); + } + + TestObject->TestMap = {{1, 100}, {2, 200}, {3, 300}}; + UDirectiveUtilMapFunctionLibrary::GenericMap_ClearValues(&TestObject->TestMap, MapProperty); + TestEqual("ClearValues should preserve the number of entries", TestObject->TestMap.Num(), 3); + TestTrue("ClearValues should preserve key 1", TestObject->TestMap.Contains(1)); + TestTrue("ClearValues should preserve key 2", TestObject->TestMap.Contains(2)); + TestTrue("ClearValues should preserve key 3", TestObject->TestMap.Contains(3)); + for (const TPair& Pair : TestObject->TestMap) + { + TestEqual("ClearValues should reset every value to its default", Pair.Value, 0); + } + + TestObject->TestMap.Empty(); + UDirectiveUtilMapFunctionLibrary::GenericMap_ClearValues(&TestObject->TestMap, MapProperty); + TestEqual("ClearValues on an empty map should leave it empty", TestObject->TestMap.Num(), 0); + + FArrayProperty* ArrayProperty = FindFProperty(UDirectiveUtilTestObject::StaticClass(), GET_MEMBER_NAME_CHECKED(UDirectiveUtilTestObject, TestArray)); + FMapProperty* MapProperty2 = FindFProperty(UDirectiveUtilTestObject::StaticClass(), GET_MEMBER_NAME_CHECKED(UDirectiveUtilTestObject, TestMap2)); + FMapProperty* StringKeyMapProperty = FindFProperty(UDirectiveUtilTestObject::StaticClass(), GET_MEMBER_NAME_CHECKED(UDirectiveUtilTestObject, TestStringKeyMap)); + TestNotNull("TestArray property should be found", ArrayProperty); + TestNotNull("TestMap2 property should be found", MapProperty2); + TestNotNull("TestStringKeyMap property should be found", StringKeyMapProperty); + if (!ArrayProperty || !MapProperty2 || !StringKeyMapProperty) + { + return false; + } + + TestObject->TestMap = {{1, 10}, {2, 20}, {3, 10}}; + TestObject->TestArray = {42}; + int32 SearchValue = 10; + UDirectiveUtilMapFunctionLibrary::GenericMap_GetKeysByValue(&TestObject->TestMap, MapProperty, &SearchValue, &TestObject->TestArray, ArrayProperty); + TestEqual("GetKeysByValue should find two keys for a shared value", TestObject->TestArray.Num(), 2); + if (TestObject->TestArray.Num() == 2) + { + TestEqual("GetKeysByValue should return the first matching key", TestObject->TestArray[0], 1); + TestEqual("GetKeysByValue should return the second matching key", TestObject->TestArray[1], 3); + } + + SearchValue = 99; + UDirectiveUtilMapFunctionLibrary::GenericMap_GetKeysByValue(&TestObject->TestMap, MapProperty, &SearchValue, &TestObject->TestArray, ArrayProperty); + TestEqual("GetKeysByValue should return no keys for an absent value", TestObject->TestArray.Num(), 0); + + TestObject->TestArray = {42}; + SearchValue = 10; + UDirectiveUtilMapFunctionLibrary::GenericMap_GetKeysByValue(nullptr, MapProperty, &SearchValue, &TestObject->TestArray, ArrayProperty); + TestEqual("GetKeysByValue with a null map should empty the output array", TestObject->TestArray.Num(), 0); + TestObject->TestArray = {42}; + UDirectiveUtilMapFunctionLibrary::GenericMap_GetKeysByValue(&TestObject->TestMap, nullptr, &SearchValue, &TestObject->TestArray, ArrayProperty); + TestEqual("GetKeysByValue with a null map property should empty the output array", TestObject->TestArray.Num(), 0); + TestObject->TestStringKeyMap = {{TEXT("One"), 10}}; + TestObject->TestArray = {42}; + UDirectiveUtilMapFunctionLibrary::GenericMap_GetKeysByValue(&TestObject->TestStringKeyMap, StringKeyMapProperty, &SearchValue, &TestObject->TestArray, ArrayProperty); + TestEqual("GetKeysByValue with a mismatched key type should empty the output array", TestObject->TestArray.Num(), 0); + + TestObject->TestMap = {{1, 10}, {2, 20}, {3, 10}}; + int32 QueryValue = 20; + TestTrue("HasValue should find a present value", UDirectiveUtilMapFunctionLibrary::GenericMap_HasValue(&TestObject->TestMap, MapProperty, &QueryValue)); + QueryValue = 99; + TestFalse("HasValue should not find an absent value", UDirectiveUtilMapFunctionLibrary::GenericMap_HasValue(&TestObject->TestMap, MapProperty, &QueryValue)); + TestObject->TestMap.Empty(); + QueryValue = 20; + TestFalse("HasValue on an empty map should be false", UDirectiveUtilMapFunctionLibrary::GenericMap_HasValue(&TestObject->TestMap, MapProperty, &QueryValue)); + TestFalse("HasValue with a null map should be false", UDirectiveUtilMapFunctionLibrary::GenericMap_HasValue(nullptr, MapProperty, &QueryValue)); + TestFalse("HasValue with a null map property should be false", UDirectiveUtilMapFunctionLibrary::GenericMap_HasValue(&TestObject->TestMap, nullptr, &QueryValue)); + + TestObject->TestMap = {{1, 10}, {2, 20}, {3, 10}}; + TestObject->TestArray = {1, 3, 99}; + int32 NumRemoved = UDirectiveUtilMapFunctionLibrary::GenericMap_RemoveKeys(&TestObject->TestMap, MapProperty, &TestObject->TestArray, ArrayProperty); + TestEqual("RemoveKeys should report two removed entries", NumRemoved, 2); + TestEqual("RemoveKeys should leave one entry behind", TestObject->TestMap.Num(), 1); + TestTrue("RemoveKeys should preserve the untouched key", TestObject->TestMap.Contains(2)); + + TestEqual("RemoveKeys with a null map should remove nothing", UDirectiveUtilMapFunctionLibrary::GenericMap_RemoveKeys(nullptr, MapProperty, &TestObject->TestArray, ArrayProperty), 0); + TestEqual("RemoveKeys with a null map property should remove nothing", UDirectiveUtilMapFunctionLibrary::GenericMap_RemoveKeys(&TestObject->TestMap, nullptr, &TestObject->TestArray, ArrayProperty), 0); + TestEqual("RemoveKeys with a mismatched key type should remove nothing", UDirectiveUtilMapFunctionLibrary::GenericMap_RemoveKeys(&TestObject->TestStringKeyMap, StringKeyMapProperty, &TestObject->TestArray, ArrayProperty), 0); + TestEqual("RemoveKeys with a mismatched key type should not change the map", TestObject->TestStringKeyMap.Num(), 1); + + TestObject->TestMap = {{1, 10}}; + TestObject->TestMap2 = {{1, 99}, {4, 40}}; + UDirectiveUtilMapFunctionLibrary::GenericMap_Append(&TestObject->TestMap, MapProperty, &TestObject->TestMap2, MapProperty2, true); + TestEqual("Append with overwrite should end with two entries", TestObject->TestMap.Num(), 2); + if (const int32* OverwrittenValue = TestObject->TestMap.Find(1)) + { + TestEqual("Append with overwrite should replace the existing value", *OverwrittenValue, 99); + } + if (const int32* AppendedValue = TestObject->TestMap.Find(4)) + { + TestEqual("Append with overwrite should copy the new pair", *AppendedValue, 40); + } + + TestObject->TestMap = {{1, 10}}; + UDirectiveUtilMapFunctionLibrary::GenericMap_Append(&TestObject->TestMap, MapProperty, &TestObject->TestMap2, MapProperty2, false); + TestEqual("Append without overwrite should end with two entries", TestObject->TestMap.Num(), 2); + if (const int32* PreservedValue = TestObject->TestMap.Find(1)) + { + TestEqual("Append without overwrite should preserve the existing value", *PreservedValue, 10); + } + if (const int32* NewValue = TestObject->TestMap.Find(4)) + { + TestEqual("Append without overwrite should still copy the new pair", *NewValue, 40); + } + + TestObject->TestMap = {{1, 10}}; + UDirectiveUtilMapFunctionLibrary::GenericMap_Append(nullptr, MapProperty, &TestObject->TestMap2, MapProperty2, true); + UDirectiveUtilMapFunctionLibrary::GenericMap_Append(&TestObject->TestMap, nullptr, &TestObject->TestMap2, MapProperty2, true); + UDirectiveUtilMapFunctionLibrary::GenericMap_Append(&TestObject->TestMap, MapProperty, &TestObject->TestStringKeyMap, StringKeyMapProperty, true); + TestEqual("Append with null or mismatched properties should not change the target", TestObject->TestMap.Num(), 1); + if (const int32* UntouchedValue = TestObject->TestMap.Find(1)) + { + TestEqual("Append with null or mismatched properties should preserve the value", *UntouchedValue, 10); + } + + return true; +} diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilMathFunctionLibraryTest.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilMathFunctionLibraryTest.cpp new file mode 100644 index 00000000..9e5930b8 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilMathFunctionLibraryTest.cpp @@ -0,0 +1,245 @@ +#include "Libraries/DirectiveUtilMathFunctionLibrary.h" +#include "Misc/AutomationTest.h" + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilMathFunctionLibraryTest, "DirectiveUtilities.MathFunctionLibraryTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter) + +bool FDirectiveUtilMathFunctionLibraryTest::RunTest(const FString& Parameters) +{ + TestEqual("AngleBetweenVectors should return 0 for parallel vectors", + UDirectiveUtilMathFunctionLibrary::AngleBetweenVectors(FVector::ForwardVector, FVector::ForwardVector), 0.0f); + TestTrue("AngleBetweenVectors should return ~90 for perpendicular vectors", + FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::AngleBetweenVectors(FVector::ForwardVector, FVector::RightVector), 90.0f, 0.01f)); + TestTrue("AngleBetweenVectors should return ~180 for opposite vectors", + FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::AngleBetweenVectors(FVector::ForwardVector, -FVector::ForwardVector), 180.0f, 0.01f)); + TestTrue("AngleBetweenVectors should handle zero vector gracefully", + FMath::IsFinite(UDirectiveUtilMathFunctionLibrary::AngleBetweenVectors(FVector::ZeroVector, FVector::ForwardVector))); + + { + const FVector2D Sample2D(12.34f, 56.78f); + const float Noise2DFirst = UDirectiveUtilMathFunctionLibrary::PerlinNoise2D(Sample2D); + const float Noise2DSecond = UDirectiveUtilMathFunctionLibrary::PerlinNoise2D(Sample2D); + TestEqual("PerlinNoise2D should be deterministic for the same input", Noise2DFirst, Noise2DSecond); + TestTrue("PerlinNoise2D should return a finite value", FMath::IsFinite(Noise2DFirst)); + TestTrue("PerlinNoise2D should be within [-1, 1]", + Noise2DFirst >= -1.0f - 1.e-4f && Noise2DFirst <= 1.0f + 1.e-4f); + } + + { + const FVector2D Samples2D[] = { + FVector2D(0.5f, 0.5f), + FVector2D(-3.25f, 7.1f), + FVector2D(100.123f, -200.456f), + FVector2D(0.0f, 0.0f) + }; + for (const FVector2D& Sample : Samples2D) + { + const float Value = UDirectiveUtilMathFunctionLibrary::PerlinNoise2D(Sample); + TestTrue(FString::Printf(TEXT("PerlinNoise2D should be finite at %s"), *Sample.ToString()), FMath::IsFinite(Value)); + TestTrue(FString::Printf(TEXT("PerlinNoise2D should be within [-1,1] at %s"), *Sample.ToString()), + Value >= -1.0f - 1.e-4f && Value <= 1.0f + 1.e-4f); + } + } + + TestTrue("PerlinNoise2D should be ~0 at an integer lattice point", + FMath::IsNearlyZero(UDirectiveUtilMathFunctionLibrary::PerlinNoise2D(FVector2D(3.0f, 4.0f)), 1.e-4f)); + + { + const FVector Sample3D(12.34f, 56.78f, 90.12f); + const float Noise3DFirst = UDirectiveUtilMathFunctionLibrary::PerlinNoise3D(Sample3D); + const float Noise3DSecond = UDirectiveUtilMathFunctionLibrary::PerlinNoise3D(Sample3D); + TestEqual("PerlinNoise3D should be deterministic for the same input", Noise3DFirst, Noise3DSecond); + TestTrue("PerlinNoise3D should return a finite value", FMath::IsFinite(Noise3DFirst)); + TestTrue("PerlinNoise3D should be within [-1, 1]", + Noise3DFirst >= -1.0f - 1.e-4f && Noise3DFirst <= 1.0f + 1.e-4f); + } + + { + const FVector Samples3D[] = { + FVector(0.5f, 0.5f, 0.5f), + FVector(-3.25f, 7.1f, -1.9f), + FVector(100.123f, -200.456f, 33.7f), + FVector(0.0f, 0.0f, 0.0f) + }; + for (const FVector& Sample : Samples3D) + { + const float Value = UDirectiveUtilMathFunctionLibrary::PerlinNoise3D(Sample); + TestTrue(FString::Printf(TEXT("PerlinNoise3D should be finite at %s"), *Sample.ToString()), FMath::IsFinite(Value)); + TestTrue(FString::Printf(TEXT("PerlinNoise3D should be within [-1,1] at %s"), *Sample.ToString()), + Value >= -1.0f - 1.e-4f && Value <= 1.0f + 1.e-4f); + } + } + + TestTrue("PerlinNoise3D should be ~0 at an integer lattice point", + FMath::IsNearlyZero(UDirectiveUtilMathFunctionLibrary::PerlinNoise3D(FVector(3.0f, 4.0f, 5.0f)), 1.e-4f)); + + const TArray AllEaseTypes = { + EDirectiveUtilEaseType::BackIn, EDirectiveUtilEaseType::BackOut, EDirectiveUtilEaseType::BackInOut, + EDirectiveUtilEaseType::ElasticIn, EDirectiveUtilEaseType::ElasticOut, EDirectiveUtilEaseType::ElasticInOut, + EDirectiveUtilEaseType::BounceIn, EDirectiveUtilEaseType::BounceOut, EDirectiveUtilEaseType::BounceInOut + }; + for (const EDirectiveUtilEaseType EaseType : AllEaseTypes) + { + const FString TypeName = FString::FromInt(static_cast(EaseType)); + TestTrue(FString::Printf(TEXT("EaseAlpha(0) should be ~0 for type %s"), *TypeName), + FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::EaseAlpha(0.0f, EaseType), 0.0f, 1.e-3f)); + TestTrue(FString::Printf(TEXT("EaseAlpha(1) should be ~1 for type %s"), *TypeName), + FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::EaseAlpha(1.0f, EaseType), 1.0f, 1.e-3f)); + for (const float Sample : {0.0f, 0.25f, 0.5f, 0.75f, 1.0f}) + { + TestTrue(FString::Printf(TEXT("EaseAlpha(%.2f) should be finite for type %s"), Sample, *TypeName), + FMath::IsFinite(UDirectiveUtilMathFunctionLibrary::EaseAlpha(Sample, EaseType))); + } + } + + TestEqual("EaseAlpha should clamp alpha below 0", + UDirectiveUtilMathFunctionLibrary::EaseAlpha(-1.0f, EDirectiveUtilEaseType::BounceOut), + UDirectiveUtilMathFunctionLibrary::EaseAlpha(0.0f, EDirectiveUtilEaseType::BounceOut)); + TestEqual("EaseAlpha should clamp alpha above 1", + UDirectiveUtilMathFunctionLibrary::EaseAlpha(2.0f, EDirectiveUtilEaseType::BounceOut), + UDirectiveUtilMathFunctionLibrary::EaseAlpha(1.0f, EDirectiveUtilEaseType::BounceOut)); + + for (const EDirectiveUtilEaseType BounceType : {EDirectiveUtilEaseType::BounceIn, EDirectiveUtilEaseType::BounceOut, EDirectiveUtilEaseType::BounceInOut}) + { + for (const float Sample : {0.1f, 0.3f, 0.6f, 0.9f}) + { + const float Eased = UDirectiveUtilMathFunctionLibrary::EaseAlpha(Sample, BounceType); + TestTrue("Bounce easing should stay within [0,1]", Eased >= -1.e-3f && Eased <= 1.0f + 1.e-3f); + } + } + + TestTrue("EaseFloat at alpha 0 returns A", + FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::EaseFloat(10.0f, 20.0f, 0.0f, EDirectiveUtilEaseType::BounceOut), 10.0f, 1.e-3f)); + TestTrue("EaseFloat at alpha 1 returns B", + FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::EaseFloat(10.0f, 20.0f, 1.0f, EDirectiveUtilEaseType::BounceOut), 20.0f, 1.e-3f)); + TestTrue("EaseVector at alpha 1 returns B", + UDirectiveUtilMathFunctionLibrary::EaseVector(FVector::ZeroVector, FVector(1, 2, 3), 1.0f, EDirectiveUtilEaseType::BounceOut).Equals(FVector(1, 2, 3), 1.e-2f)); + TestTrue("EaseRotator at alpha 0 returns A", + UDirectiveUtilMathFunctionLibrary::EaseRotator(FRotator(10, 20, 30), FRotator(40, 50, 60), 0.0f, EDirectiveUtilEaseType::BounceOut).Equals(FRotator(10, 20, 30), 1.e-1f)); + TestTrue("EaseColor at alpha 1 returns B", + UDirectiveUtilMathFunctionLibrary::EaseColor(FLinearColor::Black, FLinearColor::White, 1.0f, EDirectiveUtilEaseType::BounceOut).Equals(FLinearColor::White, 1.e-2f)); + + TestTrue("RoundToDecimals(3.14159, 2) ~= 3.14", + FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::RoundToDecimals(3.14159f, 2), 3.14f, 1.e-4f)); + TestTrue("RoundToDecimals(2.71828, 2) ~= 2.72 (rounds up)", + FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::RoundToDecimals(2.71828f, 2), 2.72f, 1.e-4f)); + TestTrue("RoundToDecimals(-1.2367, 2) ~= -1.24", + FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::RoundToDecimals(-1.2367f, 2), -1.24f, 1.e-4f)); + TestTrue("RoundToDecimals with 0 decimals rounds to integer", + FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::RoundToDecimals(1.6f, 0), 2.0f, 1.e-4f)); + TestTrue("RoundToDecimals(10.0, 5) ~= 10.0", + FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::RoundToDecimals(10.0f, 5), 10.0f, 1.e-4f)); + TestTrue("RoundToDecimals clamps excessive decimals without crashing", + FMath::IsFinite(UDirectiveUtilMathFunctionLibrary::RoundToDecimals(1.23456789f, 50))); + TestTrue("RoundToDecimalsAsText(3.14159, 2) reads ~3.14", + FMath::IsNearlyEqual(FCString::Atof(*UDirectiveUtilMathFunctionLibrary::RoundToDecimalsAsText(3.14159f, 2).ToString()), 3.14f, 1.e-2f)); + TestTrue("RoundToDecimals(2.5, 0) rounds half away from zero to 3", + FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::RoundToDecimals(2.5f, 0), 3.0f, 1.e-4f)); + TestTrue("RoundToDecimals(-2.5, 0) rounds half away from zero to -3", + FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::RoundToDecimals(-2.5f, 0), -3.0f, 1.e-4f)); + TestTrue("RoundToDecimals(0.125, 2) rounds half away from zero to 0.13", + FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::RoundToDecimals(0.125f, 2), 0.13f, 0.0001f)); + TestEqual("RoundToDecimalsAsText(2.5, 0) rounds half away from zero to \"3\"", + UDirectiveUtilMathFunctionLibrary::RoundToDecimalsAsText(2.5f, 0).ToString(), FString(TEXT("3"))); + TestEqual("RoundToDecimalsAsText(-2.5, 0) rounds half away from zero to \"-3\"", + UDirectiveUtilMathFunctionLibrary::RoundToDecimalsAsText(-2.5f, 0).ToString(), FString(TEXT("-3"))); + + TestEqual("Weighted random on an empty array returns INDEX_NONE", + UDirectiveUtilMathFunctionLibrary::GetRandomIndexFromWeights(TArray()), static_cast(INDEX_NONE)); + TestEqual("Weighted random with all-zero weights returns INDEX_NONE", + UDirectiveUtilMathFunctionLibrary::GetRandomIndexFromWeights({0.0f, 0.0f, 0.0f}), static_cast(INDEX_NONE)); + for (int32 Iteration = 0; Iteration < 25; ++Iteration) + { + TestEqual("Weighted random {0,1,0} always selects index 1", + UDirectiveUtilMathFunctionLibrary::GetRandomIndexFromWeights({0.0f, 1.0f, 0.0f}), 1); + TestEqual("Weighted random {5,0,0} always selects index 0", + UDirectiveUtilMathFunctionLibrary::GetRandomIndexFromWeights({5.0f, 0.0f, 0.0f}), 0); + } + + TestEqual("FormatBytes(532) is \"532 B\"", + UDirectiveUtilMathFunctionLibrary::FormatBytes(532).ToString(), FString(TEXT("532 B"))); + TestEqual("FormatBytes(1536) is \"1.5 KB\"", + UDirectiveUtilMathFunctionLibrary::FormatBytes(1536).ToString(), FString(TEXT("1.5 KB"))); + TestEqual("FormatBytes(1450000, 1) is \"1.4 MB\"", + UDirectiveUtilMathFunctionLibrary::FormatBytes(1450000, 1).ToString(), FString(TEXT("1.4 MB"))); + TestEqual("FormatBytes(0) is \"0 B\"", + UDirectiveUtilMathFunctionLibrary::FormatBytes(0).ToString(), FString(TEXT("0 B"))); + + TestEqual("FormatDuration(3785) is \"1h 03m 05s\"", + UDirectiveUtilMathFunctionLibrary::FormatDuration(3785.0f).ToString(), FString(TEXT("1h 03m 05s"))); + TestEqual("FormatDuration(3785, false) is \"1h 03m\"", + UDirectiveUtilMathFunctionLibrary::FormatDuration(3785.0f, false).ToString(), FString(TEXT("1h 03m"))); + TestEqual("FormatDuration(45) is \"45s\"", + UDirectiveUtilMathFunctionLibrary::FormatDuration(45.0f).ToString(), FString(TEXT("45s"))); + TestEqual("FormatDuration(-90) is \"-1m 30s\"", + UDirectiveUtilMathFunctionLibrary::FormatDuration(-90.0f).ToString(), FString(TEXT("-1m 30s"))); + + TestEqual("FormatRelativeTime 5 minutes back reads \"5 minutes ago\"", + UDirectiveUtilMathFunctionLibrary::FormatRelativeTime(FDateTime::Now() - FTimespan::FromMinutes(5)).ToString(), FString(TEXT("5 minutes ago"))); + TestEqual("FormatRelativeTime 10 seconds back reads \"just now\"", + UDirectiveUtilMathFunctionLibrary::FormatRelativeTime(FDateTime::Now() - FTimespan::FromSeconds(10)).ToString(), FString(TEXT("just now"))); + TestEqual("FormatRelativeTime 2 hours ahead reads \"in 2 hours\"", + UDirectiveUtilMathFunctionLibrary::FormatRelativeTime(FDateTime::Now() + FTimespan::FromHours(2)).ToString(), FString(TEXT("in 2 hours"))); + TestEqual("FormatRelativeTime 1 minute back reads \"1 minute ago\"", + UDirectiveUtilMathFunctionLibrary::FormatRelativeTime(FDateTime::Now() - FTimespan::FromMinutes(1)).ToString(), FString(TEXT("1 minute ago"))); + + { + const TArray IntValues = {1, 2, 3, 4}; + TestEqual("GetIntArraySum({1,2,3,4}) is 10", + UDirectiveUtilMathFunctionLibrary::GetIntArraySum(IntValues), static_cast(10)); + TestTrue("GetIntArrayAverage({1,2,3,4}) is 2.5", + FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::GetIntArrayAverage(IntValues), 2.5f, 1.e-4f)); + TestTrue("GetIntArrayMedian({1,2,3,4}) averages the two middle values to 2.5", + FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::GetIntArrayMedian(IntValues), 2.5f, 1.e-4f)); + TestTrue("GetIntArrayMedian({1,2,3}) is 2", + FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::GetIntArrayMedian({1, 2, 3}), 2.0f, 1.e-4f)); + TestTrue("GetIntArrayMedian handles an unsorted input", + FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::GetIntArrayMedian({3, 1, 2}), 2.0f, 1.e-4f)); + TestTrue("GetIntArrayStandardDeviation({2,4,4,4,5,5,7,9}) is the population value 2", + FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::GetIntArrayStandardDeviation({2, 4, 4, 4, 5, 5, 7, 9}), 2.0f, 1.e-4f)); + TestEqual("GetIntArraySum({MAX_int32, MAX_int32}) does not overflow", + UDirectiveUtilMathFunctionLibrary::GetIntArraySum({MAX_int32, MAX_int32}), static_cast(MAX_int32) * 2); + + const TArray FloatValues = {1.0f, 2.0f, 3.0f, 4.0f}; + TestTrue("GetFloatArraySum({1,2,3,4}) is 10", + FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::GetFloatArraySum(FloatValues), 10.0f, 1.e-4f)); + TestTrue("GetFloatArrayAverage({1,2,3,4}) is 2.5", + FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::GetFloatArrayAverage(FloatValues), 2.5f, 1.e-4f)); + TestTrue("GetFloatArrayMedian({1,2,3,4}) averages the two middle values to 2.5", + FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::GetFloatArrayMedian(FloatValues), 2.5f, 1.e-4f)); + TestTrue("GetFloatArrayMedian({1,2,3}) is 2", + FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::GetFloatArrayMedian({1.0f, 2.0f, 3.0f}), 2.0f, 1.e-4f)); + TestTrue("GetFloatArrayStandardDeviation({2,4,4,4,5,5,7,9}) is the population value 2", + FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::GetFloatArrayStandardDeviation({2.0f, 4.0f, 4.0f, 4.0f, 5.0f, 5.0f, 7.0f, 9.0f}), 2.0f, 1.e-4f)); + + const TArray EmptyInts; + const TArray EmptyFloats; + TestEqual("GetIntArraySum of an empty array is 0", + UDirectiveUtilMathFunctionLibrary::GetIntArraySum(EmptyInts), static_cast(0)); + TestEqual("GetIntArrayAverage of an empty array is 0", + UDirectiveUtilMathFunctionLibrary::GetIntArrayAverage(EmptyInts), 0.0f); + TestEqual("GetIntArrayMedian of an empty array is 0", + UDirectiveUtilMathFunctionLibrary::GetIntArrayMedian(EmptyInts), 0.0f); + TestEqual("GetIntArrayStandardDeviation of an empty array is 0", + UDirectiveUtilMathFunctionLibrary::GetIntArrayStandardDeviation(EmptyInts), 0.0f); + TestEqual("GetFloatArraySum of an empty array is 0", + UDirectiveUtilMathFunctionLibrary::GetFloatArraySum(EmptyFloats), 0.0f); + TestEqual("GetFloatArrayAverage of an empty array is 0", + UDirectiveUtilMathFunctionLibrary::GetFloatArrayAverage(EmptyFloats), 0.0f); + TestEqual("GetFloatArrayMedian of an empty array is 0", + UDirectiveUtilMathFunctionLibrary::GetFloatArrayMedian(EmptyFloats), 0.0f); + TestEqual("GetFloatArrayStandardDeviation of an empty array is 0", + UDirectiveUtilMathFunctionLibrary::GetFloatArrayStandardDeviation(EmptyFloats), 0.0f); + } + + FRandomStream StreamA(12345); + FRandomStream StreamB(12345); + const int32 StreamIndexA = UDirectiveUtilMathFunctionLibrary::GetRandomIndexFromWeightsFromStream(StreamA, {1.0f, 1.0f, 1.0f, 1.0f}); + const int32 StreamIndexB = UDirectiveUtilMathFunctionLibrary::GetRandomIndexFromWeightsFromStream(StreamB, {1.0f, 1.0f, 1.0f, 1.0f}); + TestEqual("Weighted random from stream is deterministic for the same seed", StreamIndexA, StreamIndexB); + TestTrue("Weighted random from stream returns a valid index", StreamIndexA >= 0 && StreamIndexA < 4); + FRandomStream EmptyStream(1); + TestEqual("Weighted random from stream with all-zero weights returns INDEX_NONE", + UDirectiveUtilMathFunctionLibrary::GetRandomIndexFromWeightsFromStream(EmptyStream, {0.0f, 0.0f}), static_cast(INDEX_NONE)); + + return true; +} diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilRegexFunctionLibraryTest.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilRegexFunctionLibraryTest.cpp new file mode 100644 index 00000000..1990fb4d --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilRegexFunctionLibraryTest.cpp @@ -0,0 +1,78 @@ +#include "Libraries/DirectiveUtilRegexFunctionLibrary.h" +#include "Misc/AutomationTest.h" + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilRegexFunctionLibraryTest, "DirectiveUtilities.RegexFunctionLibraryTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter) + +bool FDirectiveUtilRegexFunctionLibraryTest::RunTest(const FString& Parameters) +{ + TestTrue("RegexMatches should match digits in 'Hello123'", UDirectiveUtilRegexFunctionLibrary::RegexMatches(TEXT("Hello123"), TEXT("[0-9]+"))); + TestFalse("RegexMatches should not match digits in 'Hello'", UDirectiveUtilRegexFunctionLibrary::RegexMatches(TEXT("Hello"), TEXT("[0-9]+"))); + TestFalse("RegexMatches should be case-sensitive by default", UDirectiveUtilRegexFunctionLibrary::RegexMatches(TEXT("HELLO"), TEXT("hello"))); + TestTrue("RegexMatches should honor the case-insensitive flag", UDirectiveUtilRegexFunctionLibrary::RegexMatches(TEXT("HELLO"), TEXT("hello"), false)); + TestFalse("RegexMatches should return false for an empty pattern", UDirectiveUtilRegexFunctionLibrary::RegexMatches(TEXT("Hello"), TEXT(""))); + + { + FString Match; + int32 Start = 0; + int32 End = 0; + const bool bFound = UDirectiveUtilRegexFunctionLibrary::RegexFindFirst(TEXT("abc123def456"), TEXT("[0-9]+"), Match, Start, End); + TestTrue("RegexFindFirst should find a match", bFound); + TestEqual("RegexFindFirst should return the first match", Match, FString(TEXT("123"))); + TestEqual("RegexFindFirst should return the match start", Start, 3); + TestEqual("RegexFindFirst should return the match end (exclusive)", End, 6); + } + { + FString Match = TEXT("sentinel"); + int32 Start = 5; + int32 End = 5; + const bool bFound = UDirectiveUtilRegexFunctionLibrary::RegexFindFirst(TEXT("abc"), TEXT("[0-9]+"), Match, Start, End); + TestFalse("RegexFindFirst should report no match", bFound); + TestTrue("RegexFindFirst should clear the match on failure", Match.IsEmpty()); + TestEqual("RegexFindFirst should set start to INDEX_NONE on failure", Start, static_cast(INDEX_NONE)); + TestEqual("RegexFindFirst should set end to INDEX_NONE on failure", End, static_cast(INDEX_NONE)); + } + + TestEqual("RegexFindAll should return every match", + UDirectiveUtilRegexFunctionLibrary::RegexFindAll(TEXT("a1b2c3"), TEXT("[0-9]")), + TArray({TEXT("1"), TEXT("2"), TEXT("3")})); + TestEqual("RegexFindAll should return nothing when there are no matches", + UDirectiveUtilRegexFunctionLibrary::RegexFindAll(TEXT("abc"), TEXT("[0-9]")).Num(), 0); + TestEqual("RegexFindAll should ignore zero-width matches", + UDirectiveUtilRegexFunctionLibrary::RegexFindAll(TEXT("abc"), TEXT("x*")).Num(), 0); + TestEqual("RegexFindAll should return only the non-empty matches of a star pattern", + UDirectiveUtilRegexFunctionLibrary::RegexFindAll(TEXT("bab"), TEXT("a*")), + TArray({TEXT("a")})); + + TestEqual("RegexReplaceAll should replace every match", + UDirectiveUtilRegexFunctionLibrary::RegexReplaceAll(TEXT("a1b2c3"), TEXT("[0-9]"), TEXT("#")), FString(TEXT("a#b#c#"))); + TestEqual("RegexReplaceAll should return the input unchanged when nothing matches", + UDirectiveUtilRegexFunctionLibrary::RegexReplaceAll(TEXT("abc"), TEXT("[0-9]"), TEXT("#")), FString(TEXT("abc"))); + TestEqual("RegexReplaceAll should return the input unchanged for an empty pattern", + UDirectiveUtilRegexFunctionLibrary::RegexReplaceAll(TEXT("abc"), TEXT(""), TEXT("#")), FString(TEXT("abc"))); + TestEqual("RegexReplaceAll should only replace the non-empty matches of a star pattern", + UDirectiveUtilRegexFunctionLibrary::RegexReplaceAll(TEXT("bab"), TEXT("a*"), TEXT("X")), FString(TEXT("bXb"))); + TestEqual("RegexReplaceAll should still replace every non-empty match", + UDirectiveUtilRegexFunctionLibrary::RegexReplaceAll(TEXT("aaa"), TEXT("a"), TEXT("b")), FString(TEXT("bbb"))); + + { + FString Group; + TestTrue("RegexGetCaptureGroup should return the whole match for group 0", + UDirectiveUtilRegexFunctionLibrary::RegexGetCaptureGroup(TEXT("2024-06-24"), TEXT("([0-9]+)-([0-9]+)-([0-9]+)"), 0, Group)); + TestEqual("RegexGetCaptureGroup group 0 should be the whole match", Group, FString(TEXT("2024-06-24"))); + + TestTrue("RegexGetCaptureGroup should return group 1", + UDirectiveUtilRegexFunctionLibrary::RegexGetCaptureGroup(TEXT("2024-06-24"), TEXT("([0-9]+)-([0-9]+)-([0-9]+)"), 1, Group)); + TestEqual("RegexGetCaptureGroup group 1 should be the year", Group, FString(TEXT("2024"))); + + TestTrue("RegexGetCaptureGroup should return group 2", + UDirectiveUtilRegexFunctionLibrary::RegexGetCaptureGroup(TEXT("2024-06-24"), TEXT("([0-9]+)-([0-9]+)-([0-9]+)"), 2, Group)); + TestEqual("RegexGetCaptureGroup group 2 should be the month", Group, FString(TEXT("06"))); + + Group = TEXT("sentinel"); + TestFalse("RegexGetCaptureGroup should fail for a non-existent group", + UDirectiveUtilRegexFunctionLibrary::RegexGetCaptureGroup(TEXT("2024-06-24"), TEXT("([0-9]+)-([0-9]+)-([0-9]+)"), 9, Group)); + TestTrue("RegexGetCaptureGroup should clear the output for a non-existent group", Group.IsEmpty()); + } + + return true; +} diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilSaveGameFunctionLibraryTest.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilSaveGameFunctionLibraryTest.cpp new file mode 100644 index 00000000..3b505680 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilSaveGameFunctionLibraryTest.cpp @@ -0,0 +1,122 @@ +#include "Libraries/DirectiveUtilSaveGameFunctionLibrary.h" +#include "Tests/DirectiveUtilTestObject.h" +#include "Kismet/GameplayStatics.h" +#include "GameFramework/SaveGame.h" +#include "Misc/AutomationTest.h" + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilSaveGameFunctionLibraryTest, "DirectiveUtilities.SaveGameFunctionLibraryTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter) + +bool FDirectiveUtilSaveGameFunctionLibraryTest::RunTest(const FString& Parameters) +{ + { + USaveGame* Save = NewObject(); + TArray Bytes; + const bool bSaved = UDirectiveUtilSaveGameFunctionLibrary::SaveGameToBytes(Save, Bytes); + TestTrue("SaveGameToBytes should succeed for a valid object", bSaved); + TestTrue("SaveGameToBytes should produce non-empty data", Bytes.Num() > 0); + + USaveGame* Loaded = UDirectiveUtilSaveGameFunctionLibrary::LoadGameFromBytes(Bytes); + TestNotNull("LoadGameFromBytes should return a valid object", Loaded); + } + + { + TArray Bytes; + Bytes.Add(1); + TestFalse("SaveGameToBytes should fail for a null object", UDirectiveUtilSaveGameFunctionLibrary::SaveGameToBytes(nullptr, Bytes)); + TestEqual("SaveGameToBytes should clear the output for a null object", Bytes.Num(), 0); + TestNull("LoadGameFromBytes should return null for empty data", UDirectiveUtilSaveGameFunctionLibrary::LoadGameFromBytes(TArray())); + } + + { + const FString TestSlot = TEXT("DirectiveUtilitiesAutomationTestSlot"); + UGameplayStatics::DeleteGameInSlot(TestSlot, 0); + + USaveGame* Save = NewObject(); + const bool bWritten = UGameplayStatics::SaveGameToSlot(Save, TestSlot, 0); + TestTrue("SaveGameToSlot should write the temporary test slot", bWritten); + + if (bWritten) + { + TestTrue("GetAllSaveSlotNames should include the written slot", + UDirectiveUtilSaveGameFunctionLibrary::GetAllSaveSlotNames().Contains(TestSlot)); + + FDateTime Timestamp; + TestTrue("GetSaveSlotTimestamp should succeed for an existing slot", + UDirectiveUtilSaveGameFunctionLibrary::GetSaveSlotTimestamp(TestSlot, Timestamp)); + TestTrue("GetSaveSlotTimestamp should return local time for a fresh save", + FMath::Abs((FDateTime::Now() - Timestamp).GetTotalMinutes()) < 5.0); + } + + // Clean up + UGameplayStatics::DeleteGameInSlot(TestSlot, 0); + TestFalse("A deleted slot should no longer be listed", + UDirectiveUtilSaveGameFunctionLibrary::GetAllSaveSlotNames().Contains(TestSlot)); + + FDateTime MissingTimestamp; + TestFalse("GetSaveSlotTimestamp should fail for a missing slot", + UDirectiveUtilSaveGameFunctionLibrary::GetSaveSlotTimestamp(TestSlot, MissingTimestamp)); + } + + { + const FString SlotA = TEXT("DirectiveUtilitiesAutomationTestSlotA"); + const FString SlotB = TEXT("DirectiveUtilitiesAutomationTestSlotB"); + const FString SlotC = TEXT("DirectiveUtilitiesAutomationTestSlotC"); + UGameplayStatics::DeleteGameInSlot(SlotA, 0); + UGameplayStatics::DeleteGameInSlot(SlotB, 0); + UGameplayStatics::DeleteGameInSlot(SlotC, 0); + + USaveGame* Save = NewObject(); + const bool bWritten = UGameplayStatics::SaveGameToSlot(Save, SlotA, 0); + TestTrue("SaveGameToSlot should write the slot-management test slot", bWritten); + + if (bWritten) + { + TestTrue("DoesSaveSlotExist should find the written slot", + UDirectiveUtilSaveGameFunctionLibrary::DoesSaveSlotExist(SlotA)); + TestFalse("DoesSaveSlotExist should not find a missing slot", + UDirectiveUtilSaveGameFunctionLibrary::DoesSaveSlotExist(SlotB)); + + TestTrue("RenameSaveSlot should rename onto a free slot name", + UDirectiveUtilSaveGameFunctionLibrary::RenameSaveSlot(SlotA, SlotB)); + TestFalse("RenameSaveSlot should remove the old slot", + UDirectiveUtilSaveGameFunctionLibrary::DoesSaveSlotExist(SlotA)); + TestTrue("RenameSaveSlot should create the new slot", + UDirectiveUtilSaveGameFunctionLibrary::DoesSaveSlotExist(SlotB)); + TestNotNull("A renamed slot should still deserialize", + UGameplayStatics::LoadGameFromSlot(SlotB, 0)); + + USaveGame* OtherSave = NewObject(); + TestTrue("SaveGameToSlot should write the collision test slot", + UGameplayStatics::SaveGameToSlot(OtherSave, SlotC, 0)); + TestFalse("RenameSaveSlot should refuse to rename onto an existing slot", + UDirectiveUtilSaveGameFunctionLibrary::RenameSaveSlot(SlotB, SlotC)); + TestTrue("A refused rename should keep the source slot", + UDirectiveUtilSaveGameFunctionLibrary::DoesSaveSlotExist(SlotB)); + TestTrue("A refused rename should keep the target slot", + UDirectiveUtilSaveGameFunctionLibrary::DoesSaveSlotExist(SlotC)); + + TestFalse("DoesSaveSlotExist should reject an invalid slot name", + UDirectiveUtilSaveGameFunctionLibrary::DoesSaveSlotExist(TEXT("../escape"))); + TestFalse("DeleteSaveSlot should reject an invalid slot name", + UDirectiveUtilSaveGameFunctionLibrary::DeleteSaveSlot(TEXT("../escape"))); + TestFalse("RenameSaveSlot should reject an invalid source slot name", + UDirectiveUtilSaveGameFunctionLibrary::RenameSaveSlot(TEXT("../escape"), SlotC)); + TestFalse("RenameSaveSlot should reject an invalid destination slot name", + UDirectiveUtilSaveGameFunctionLibrary::RenameSaveSlot(SlotC, TEXT("../escape"))); + TestTrue("A rejected rename should leave the source slot intact", + UDirectiveUtilSaveGameFunctionLibrary::DoesSaveSlotExist(SlotC)); + + TestTrue("DeleteSaveSlot should delete an existing slot", + UDirectiveUtilSaveGameFunctionLibrary::DeleteSaveSlot(SlotB)); + TestFalse("A deleted slot should no longer exist", + UDirectiveUtilSaveGameFunctionLibrary::DoesSaveSlotExist(SlotB)); + } + + // Clean up + UGameplayStatics::DeleteGameInSlot(SlotA, 0); + UGameplayStatics::DeleteGameInSlot(SlotB, 0); + UGameplayStatics::DeleteGameInSlot(SlotC, 0); + } + + return true; +} diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilStringFunctionLibraryTest.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilStringFunctionLibraryTest.cpp new file mode 100644 index 00000000..9a934aff --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilStringFunctionLibraryTest.cpp @@ -0,0 +1,277 @@ +#include "Libraries/DirectiveUtilStringFunctionLibrary.h" +#include "Misc/AutomationTest.h" + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilStringFunctionLibraryTest, "DirectiveUtilities.StringFunctionLibraryTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter) + +bool FDirectiveUtilStringFunctionLibraryTest::RunTest(const FString& Parameters) +{ + TestTrue("ContainsLetters should return true for 'Hello'", UDirectiveUtilStringFunctionLibrary::ContainsLetters(TEXT("Hello"))); + TestFalse("ContainsLetters should return false for '1234'", UDirectiveUtilStringFunctionLibrary::ContainsLetters(TEXT("1234"))); + + TestTrue("ContainsNumbers should return true for '1234'", UDirectiveUtilStringFunctionLibrary::ContainsNumbers(TEXT("1234"))); + TestFalse("ContainsNumbers should return false for 'Hello'", UDirectiveUtilStringFunctionLibrary::ContainsNumbers(TEXT("Hello"))); + + TestTrue("ContainsSpaces should return true for 'Hello World'", UDirectiveUtilStringFunctionLibrary::ContainsSpaces(TEXT("Hello World"))); + TestFalse("ContainsSpaces should return false for 'HelloWorld'", UDirectiveUtilStringFunctionLibrary::ContainsSpaces(TEXT("HelloWorld"))); + + TestTrue("ContainsSpecialCharacters should return true for 'Hello!'", UDirectiveUtilStringFunctionLibrary::ContainsSpecialCharacters(TEXT("Hello!"))); + TestFalse("ContainsSpecialCharacters should return false for 'Hello'", UDirectiveUtilStringFunctionLibrary::ContainsSpecialCharacters(TEXT("Hello"))); + + const FString FilteredString = UDirectiveUtilStringFunctionLibrary::FilterCharacters(TEXT("Hello123! "), true, true, true, true); + TestEqual("FilterCharacters should return an empty string", FilteredString, TEXT("")); + + const TArray UnsortedArray = { TEXT("Banana"), TEXT("Apple"), TEXT("Cherry") }; + const TArray SortedArray = UDirectiveUtilStringFunctionLibrary::GetSortedStringArray(UnsortedArray); + TestEqual("GetSortedStringArray should return a sorted array", SortedArray, TArray({ TEXT("Apple"), TEXT("Banana"), TEXT("Cherry") })); + + TestEqual("TruncateString should not truncate short strings", + UDirectiveUtilStringFunctionLibrary::TruncateString(TEXT("Hello"), 10), FString(TEXT("Hello"))); + TestEqual("TruncateString should truncate long strings with suffix", + UDirectiveUtilStringFunctionLibrary::TruncateString(TEXT("Hello World"), 8), FString(TEXT("Hello..."))); + TestEqual("TruncateString should not truncate strings at exact max length", + UDirectiveUtilStringFunctionLibrary::TruncateString(TEXT("Hello"), 5), FString(TEXT("Hello"))); + + TestEqual("ToTitleCase should capitalize first letter of each word", + UDirectiveUtilStringFunctionLibrary::ToTitleCase(TEXT("hello world")), FString(TEXT("Hello World"))); + TestEqual("ToTitleCase should handle all caps", + UDirectiveUtilStringFunctionLibrary::ToTitleCase(TEXT("HELLO WORLD")), FString(TEXT("Hello World"))); + TestEqual("ToTitleCase should handle single word", + UDirectiveUtilStringFunctionLibrary::ToTitleCase(TEXT("hello")), FString(TEXT("Hello"))); + TestEqual("ToTitleCase should handle empty string", + UDirectiveUtilStringFunctionLibrary::ToTitleCase(TEXT("")), FString(TEXT(""))); + + // When MaxLength < Suffix.Len(), MaxLength - Suffix.Len() is negative; FString::Left clamps + // negative counts to 0, so the result is just the suffix (no crash). + TestEqual("TruncateString should return only the suffix when MaxLength is less than the suffix length", + UDirectiveUtilStringFunctionLibrary::TruncateString(TEXT("Hello World"), 2), FString(TEXT("..."))); + TestEqual("TruncateString should return only the suffix when MaxLength is zero", + UDirectiveUtilStringFunctionLibrary::TruncateString(TEXT("Hello World"), 0), FString(TEXT("..."))); + TestEqual("TruncateString should return only the suffix when MaxLength equals the suffix length", + UDirectiveUtilStringFunctionLibrary::TruncateString(TEXT("Hello World"), 3), FString(TEXT("..."))); + TestEqual("TruncateString should not truncate an empty input string", + UDirectiveUtilStringFunctionLibrary::TruncateString(TEXT(""), 5), FString(TEXT(""))); + TestEqual("TruncateString should honor a custom suffix", + UDirectiveUtilStringFunctionLibrary::TruncateString(TEXT("Hello World"), 6, TEXT(">>")), FString(TEXT("Hell>>"))); + TestEqual("TruncateString should return only a custom suffix when MaxLength is less than its length", + UDirectiveUtilStringFunctionLibrary::TruncateString(TEXT("Hello World"), 1, TEXT(">>")), FString(TEXT(">>"))); + TestEqual("TruncateString should handle an empty suffix by returning a hard cut", + UDirectiveUtilStringFunctionLibrary::TruncateString(TEXT("Hello World"), 5, TEXT("")), FString(TEXT("Hello"))); + + TestFalse("ContainsLetters should return false for an empty string", UDirectiveUtilStringFunctionLibrary::ContainsLetters(TEXT(""))); + TestFalse("ContainsNumbers should return false for an empty string", UDirectiveUtilStringFunctionLibrary::ContainsNumbers(TEXT(""))); + TestFalse("ContainsSpaces should return false for an empty string", UDirectiveUtilStringFunctionLibrary::ContainsSpaces(TEXT(""))); + TestFalse("ContainsSpecialCharacters should return false for an empty string", UDirectiveUtilStringFunctionLibrary::ContainsSpecialCharacters(TEXT(""))); + + TestTrue("ContainsSpaces should treat a tab as whitespace", UDirectiveUtilStringFunctionLibrary::ContainsSpaces(TEXT("Hello\tWorld"))); + TestFalse("ContainsSpecialCharacters should return false for letters and digits only", UDirectiveUtilStringFunctionLibrary::ContainsSpecialCharacters(TEXT("Hello123"))); + + TestEqual("FilterCharacters should strip only letters", + UDirectiveUtilStringFunctionLibrary::FilterCharacters(TEXT("Hello123! "), true, false, false, false), FString(TEXT("123! "))); + TestEqual("FilterCharacters should strip only numbers", + UDirectiveUtilStringFunctionLibrary::FilterCharacters(TEXT("Hello123! "), false, true, false, false), FString(TEXT("Hello! "))); + TestEqual("FilterCharacters should strip only special characters", + UDirectiveUtilStringFunctionLibrary::FilterCharacters(TEXT("Hello123! "), false, false, true, false), FString(TEXT("Hello123 "))); + TestEqual("FilterCharacters should strip only spaces", + UDirectiveUtilStringFunctionLibrary::FilterCharacters(TEXT("Hello123! "), false, false, false, true), FString(TEXT("Hello123!"))); + TestEqual("FilterCharacters should return the original string when no flags are set", + UDirectiveUtilStringFunctionLibrary::FilterCharacters(TEXT("Hello123! "), false, false, false, false), FString(TEXT("Hello123! "))); + TestEqual("FilterCharacters should return an empty string for an empty input", + UDirectiveUtilStringFunctionLibrary::FilterCharacters(TEXT(""), true, true, true, true), FString(TEXT(""))); + + { + const FString EquivalentInputs[] = { + TEXT("my var name"), TEXT("my_var_name"), TEXT("my-var-name"), TEXT("MyVarName"), TEXT("myVarName") + }; + for (const FString& Input : EquivalentInputs) + { + TestEqual(FString::Printf(TEXT("ToCamelCase(\"%s\") is \"myVarName\""), *Input), + UDirectiveUtilStringFunctionLibrary::ToCamelCase(Input), FString(TEXT("myVarName"))); + TestEqual(FString::Printf(TEXT("ToPascalCase(\"%s\") is \"MyVarName\""), *Input), + UDirectiveUtilStringFunctionLibrary::ToPascalCase(Input), FString(TEXT("MyVarName"))); + TestEqual(FString::Printf(TEXT("ToSnakeCase(\"%s\") is \"my_var_name\""), *Input), + UDirectiveUtilStringFunctionLibrary::ToSnakeCase(Input), FString(TEXT("my_var_name"))); + TestEqual(FString::Printf(TEXT("ToKebabCase(\"%s\") is \"my-var-name\""), *Input), + UDirectiveUtilStringFunctionLibrary::ToKebabCase(Input), FString(TEXT("my-var-name"))); + } + + TestEqual("SplitIntoWords keeps acronyms together", + UDirectiveUtilStringFunctionLibrary::SplitIntoWords(TEXT("XMLParser")), + TArray({ TEXT("XML"), TEXT("Parser") })); + TestEqual("SplitIntoWords splits on letter/digit transitions", + UDirectiveUtilStringFunctionLibrary::SplitIntoWords(TEXT("version2Beta")), + TArray({ TEXT("version"), TEXT("2"), TEXT("Beta") })); + TestEqual("SplitIntoWords of an empty string is an empty array", + UDirectiveUtilStringFunctionLibrary::SplitIntoWords(TEXT("")), TArray()); + + TestEqual("ToCamelCase(\"XMLParser\") is \"xmlParser\"", + UDirectiveUtilStringFunctionLibrary::ToCamelCase(TEXT("XMLParser")), FString(TEXT("xmlParser"))); + TestEqual("ToSnakeCase(\"XMLParser\") is \"xml_parser\"", + UDirectiveUtilStringFunctionLibrary::ToSnakeCase(TEXT("XMLParser")), FString(TEXT("xml_parser"))); + TestEqual("ToSnakeCase(\"version2Beta\") is \"version_2_beta\"", + UDirectiveUtilStringFunctionLibrary::ToSnakeCase(TEXT("version2Beta")), FString(TEXT("version_2_beta"))); + + TestEqual("ToCamelCase of an empty string is empty", + UDirectiveUtilStringFunctionLibrary::ToCamelCase(TEXT("")), FString(TEXT(""))); + TestEqual("ToPascalCase of an empty string is empty", + UDirectiveUtilStringFunctionLibrary::ToPascalCase(TEXT("")), FString(TEXT(""))); + TestEqual("ToSnakeCase of an empty string is empty", + UDirectiveUtilStringFunctionLibrary::ToSnakeCase(TEXT("")), FString(TEXT(""))); + TestEqual("ToKebabCase of an empty string is empty", + UDirectiveUtilStringFunctionLibrary::ToKebabCase(TEXT("")), FString(TEXT(""))); + } + + const TArray UnsortedForLegacy = { TEXT("Banana"), TEXT("Apple"), TEXT("Cherry") }; + TestEqual("SortStringArray should return a sorted array", + UDirectiveUtilStringFunctionLibrary::SortStringArray(UnsortedForLegacy), + TArray({ TEXT("Apple"), TEXT("Banana"), TEXT("Cherry") })); + TestEqual("SortStringArray should return an empty array for empty input", + UDirectiveUtilStringFunctionLibrary::SortStringArray(TArray()), TArray()); + + TestEqual("GetSortedStringArray should return an empty array for empty input", + UDirectiveUtilStringFunctionLibrary::GetSortedStringArray(TArray()), TArray()); + + TestEqual("Levenshtein of identical strings is 0", + UDirectiveUtilStringFunctionLibrary::GetLevenshteinDistance(TEXT("abc"), TEXT("abc")), 0); + TestEqual("Levenshtein kitten->sitting is 3", + UDirectiveUtilStringFunctionLibrary::GetLevenshteinDistance(TEXT("kitten"), TEXT("sitting")), 3); + TestEqual("Levenshtein from empty equals the other length", + UDirectiveUtilStringFunctionLibrary::GetLevenshteinDistance(TEXT(""), TEXT("abc")), 3); + TestEqual("Levenshtein is case-sensitive by default", + UDirectiveUtilStringFunctionLibrary::GetLevenshteinDistance(TEXT("ABC"), TEXT("abc")), 3); + TestEqual("Levenshtein honors the case-insensitive flag", + UDirectiveUtilStringFunctionLibrary::GetLevenshteinDistance(TEXT("ABC"), TEXT("abc"), /*bCaseSensitive*/ false), 0); + + TestTrue("Similarity of identical strings is 1", + FMath::IsNearlyEqual(UDirectiveUtilStringFunctionLibrary::GetStringSimilarity(TEXT("abc"), TEXT("abc")), 1.0f, 1.e-4f)); + TestTrue("Similarity of two empty strings is 1", + FMath::IsNearlyEqual(UDirectiveUtilStringFunctionLibrary::GetStringSimilarity(TEXT(""), TEXT("")), 1.0f, 1.e-4f)); + TestTrue("Similarity of completely different equal-length strings is 0", + FMath::IsNearlyEqual(UDirectiveUtilStringFunctionLibrary::GetStringSimilarity(TEXT("abc"), TEXT("xyz")), 0.0f, 1.e-4f)); + TestTrue("Similarity kitten/sitting is ~0.571", + FMath::IsNearlyEqual(UDirectiveUtilStringFunctionLibrary::GetStringSimilarity(TEXT("kitten"), TEXT("sitting")), 1.0f - 3.0f / 7.0f, 1.e-3f)); + + TestTrue("ContainsAny finds a present term", + UDirectiveUtilStringFunctionLibrary::ContainsAny(TEXT("Hello World"), {TEXT("foo"), TEXT("World")})); + TestFalse("ContainsAny returns false when no term is present", + UDirectiveUtilStringFunctionLibrary::ContainsAny(TEXT("Hello"), {TEXT("foo"), TEXT("bar")})); + TestTrue("ContainsAny honors the case-insensitive flag", + UDirectiveUtilStringFunctionLibrary::ContainsAny(TEXT("HELLO"), {TEXT("hello")}, /*bCaseSensitive*/ false)); + TestFalse("ContainsAny ignores empty terms", + UDirectiveUtilStringFunctionLibrary::ContainsAny(TEXT("Hello"), {TEXT("")})); + + { + int32 FoundIndex = 0; + int32 TermIndex = 0; + const bool bFound = UDirectiveUtilStringFunctionLibrary::FindFirstOfAny(TEXT("abcXYZ123"), {TEXT("123"), TEXT("XYZ")}, true, FoundIndex, TermIndex); + TestTrue("FindFirstOfAny should find a term", bFound); + TestEqual("FindFirstOfAny should return the earliest match index", FoundIndex, 3); + TestEqual("FindFirstOfAny should return which term matched earliest", TermIndex, 1); + } + { + int32 FoundIndex = 5; + int32 TermIndex = 5; + const bool bFound = UDirectiveUtilStringFunctionLibrary::FindFirstOfAny(TEXT("abc"), {TEXT("x"), TEXT("y")}, true, FoundIndex, TermIndex); + TestFalse("FindFirstOfAny should report no match", bFound); + TestEqual("FindFirstOfAny should set found index to INDEX_NONE on failure", FoundIndex, static_cast(INDEX_NONE)); + TestEqual("FindFirstOfAny should set term index to INDEX_NONE on failure", TermIndex, static_cast(INDEX_NONE)); + } + + { + const FString Original = TEXT("Hello, DirectiveUtilities! 123"); + const FString Encoded = UDirectiveUtilStringFunctionLibrary::Base64Encode(Original); + TestFalse("Base64Encode should produce a non-empty string", Encoded.IsEmpty()); + FString Decoded; + const bool bDecoded = UDirectiveUtilStringFunctionLibrary::Base64Decode(Encoded, Decoded); + TestTrue("Base64Decode should succeed for valid input", bDecoded); + TestEqual("Base64 should round-trip the original string", Decoded, Original); + } + + { + TestEqual("HexEncode(\"Hi!\") is \"486921\"", + UDirectiveUtilStringFunctionLibrary::HexEncode(TEXT("Hi!")), FString(TEXT("486921"))); + TestEqual("HexEncode of an empty string is empty", + UDirectiveUtilStringFunctionLibrary::HexEncode(TEXT("")), FString(TEXT(""))); + + FString Decoded; + TestTrue("HexDecode should decode valid hex", UDirectiveUtilStringFunctionLibrary::HexDecode(TEXT("486921"), Decoded)); + TestEqual("HexDecode should round-trip HexEncode", Decoded, FString(TEXT("Hi!"))); + TestTrue("HexDecode should accept uppercase hex", UDirectiveUtilStringFunctionLibrary::HexDecode(TEXT("4A"), Decoded)); + TestEqual("HexDecode of \"4A\" is \"J\"", Decoded, FString(TEXT("J"))); + TestFalse("HexDecode should reject non-hex characters", UDirectiveUtilStringFunctionLibrary::HexDecode(TEXT("XYZ"), Decoded)); + TestFalse("HexDecode should reject odd-length input", UDirectiveUtilStringFunctionLibrary::HexDecode(TEXT("48692"), Decoded)); + + TestEqual("HexEncodeBytes({0x00, 0xFF}) is \"00ff\"", + UDirectiveUtilStringFunctionLibrary::HexEncodeBytes({0x00, 0xFF}), FString(TEXT("00ff"))); + TArray DecodedBytes; + TestTrue("HexDecodeBytes should decode valid hex", UDirectiveUtilStringFunctionLibrary::HexDecodeBytes(TEXT("00ff"), DecodedBytes)); + TestEqual("HexDecodeBytes should round-trip HexEncodeBytes", DecodedBytes, TArray({0x00, 0xFF})); + TestFalse("HexDecodeBytes should reject non-hex characters", UDirectiveUtilStringFunctionLibrary::HexDecodeBytes(TEXT("zz"), DecodedBytes)); + } + + TestEqual("Md5HashString(\"abc\") matches the known vector", + UDirectiveUtilStringFunctionLibrary::Md5HashString(TEXT("abc")), FString(TEXT("900150983cd24fb0d6963f7d28e17f72"))); + TestEqual("Md5HashString of an empty string matches the known vector", + UDirectiveUtilStringFunctionLibrary::Md5HashString(TEXT("")), FString(TEXT("d41d8cd98f00b204e9800998ecf8427e"))); + TestEqual("Sha1HashString(\"abc\") matches the known vector", + UDirectiveUtilStringFunctionLibrary::Sha1HashString(TEXT("abc")), FString(TEXT("a9993e364706816aba3e25717850c26c9cd0d89d"))); + TestEqual("Sha1HashBytes of {0x61,0x62,0x63} matches the known vector", + UDirectiveUtilStringFunctionLibrary::Sha1HashBytes({0x61, 0x62, 0x63}), FString(TEXT("a9993e364706816aba3e25717850c26c9cd0d89d"))); + TestEqual("Crc32String(\"abc\") equals Crc32Bytes of its ASCII bytes", + UDirectiveUtilStringFunctionLibrary::Crc32String(TEXT("abc")), + UDirectiveUtilStringFunctionLibrary::Crc32Bytes({0x61, 0x62, 0x63})); + TestEqual("Crc32String of an empty string is 0", + UDirectiveUtilStringFunctionLibrary::Crc32String(TEXT("")), 0); + TestEqual("Md5HashString hashes the UTF-8 bytes of non-ASCII input", + UDirectiveUtilStringFunctionLibrary::Md5HashString(TEXT("\u00E9")), // e-acute; UTF-8 bytes C3 A9 + UDirectiveUtilStringFunctionLibrary::Md5HashBytes({0xC3, 0xA9})); + + TestTrue("IsValidFileName should accept a simple name", UDirectiveUtilStringFunctionLibrary::IsValidFileName(TEXT("Save01"))); + TestTrue("IsValidFileName should accept a name with a space and extension", UDirectiveUtilStringFunctionLibrary::IsValidFileName(TEXT("my save.sav"))); + TestFalse("IsValidFileName should reject a relative segment", UDirectiveUtilStringFunctionLibrary::IsValidFileName(TEXT("../escape"))); + TestFalse("IsValidFileName should reject a forward slash", UDirectiveUtilStringFunctionLibrary::IsValidFileName(TEXT("a/b"))); + TestFalse("IsValidFileName should reject a backslash", UDirectiveUtilStringFunctionLibrary::IsValidFileName(TEXT("a\\b"))); + TestFalse("IsValidFileName should reject an empty string", UDirectiveUtilStringFunctionLibrary::IsValidFileName(TEXT(""))); + TestFalse("IsValidFileName should reject a reserved character", UDirectiveUtilStringFunctionLibrary::IsValidFileName(TEXT("a:b"))); + + { + const FString Sanitized = UDirectiveUtilStringFunctionLibrary::SanitizeFileName(TEXT("../a/b?.sav")); + TestTrue("SanitizeFileName should produce a name that IsValidFileName accepts", + UDirectiveUtilStringFunctionLibrary::IsValidFileName(Sanitized)); + const FString Replaced = UDirectiveUtilStringFunctionLibrary::SanitizeFileName(TEXT("a/b"), TEXT("_")); + TestTrue("SanitizeFileName should substitute the replacement character for stripped characters", + Replaced.Contains(TEXT("_"))); + TestTrue("SanitizeFileName with a replacement should still produce a valid file name", + UDirectiveUtilStringFunctionLibrary::IsValidFileName(Replaced)); + } + + { + float Similarity = -1.0f; + TestEqual("FindBestStringMatch should return the exact match", + UDirectiveUtilStringFunctionLibrary::FindBestStringMatch(TEXT("color"), {TEXT("colour"), TEXT("color")}, Similarity), 1); + TestTrue("FindBestStringMatch should report similarity 1 for an exact match", + FMath::IsNearlyEqual(Similarity, 1.0f, 1.e-4f)); + + TestEqual("FindBestStringMatch should return the nearest match", + UDirectiveUtilStringFunctionLibrary::FindBestStringMatch(TEXT("colr"), {TEXT("color"), TEXT("colour"), TEXT("colt")}, Similarity), 0); + + TestEqual("FindBestStringMatch should be case-insensitive by default", + UDirectiveUtilStringFunctionLibrary::FindBestStringMatch(TEXT("COLOR"), {TEXT("apple"), TEXT("color")}, Similarity), 1); + TestTrue("FindBestStringMatch should report similarity 1 for a case-insensitive match", + FMath::IsNearlyEqual(Similarity, 1.0f, 1.e-4f)); + + TestEqual("FindBestStringMatch should honor the case-sensitive flag", + UDirectiveUtilStringFunctionLibrary::FindBestStringMatch(TEXT("COLOR"), {TEXT("color"), TEXT("COLT")}, Similarity, /*bCaseSensitive*/ true), 1); + + TestEqual("FindBestStringMatch should return -1 for an empty array", + UDirectiveUtilStringFunctionLibrary::FindBestStringMatch(TEXT("color"), TArray(), Similarity), static_cast(INDEX_NONE)); + TestTrue("FindBestStringMatch should report similarity 0 for an empty array", + FMath::IsNearlyEqual(Similarity, 0.0f, 1.e-4f)); + + TestEqual("FindBestStringMatch should still return an index for an empty input", + UDirectiveUtilStringFunctionLibrary::FindBestStringMatch(TEXT(""), {TEXT("abc")}, Similarity), 0); + TestTrue("FindBestStringMatch should report similarity 0 for an empty input against a non-empty candidate", + FMath::IsNearlyEqual(Similarity, 0.0f, 1.e-4f)); + } + + return true; +} diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilTextFunctionLibraryTest.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilTextFunctionLibraryTest.cpp new file mode 100644 index 00000000..8e9731f4 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilTextFunctionLibraryTest.cpp @@ -0,0 +1,15 @@ +#include "Libraries/DirectiveUtilTextFunctionLibrary.h" +#include "Misc/AutomationTest.h" + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilTextFunctionLibraryTest, "DirectiveUtilities.TextFunctionLibraryTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter) + +bool FDirectiveUtilTextFunctionLibraryTest::RunTest(const FString& Parameters) +{ + const FText NonEmptyText = FText::FromString(TEXT("Hello")); + TestTrue("IsNotEmpty should return true for non-empty text", UDirectiveUtilTextFunctionLibrary::IsNotEmpty(NonEmptyText)); + + const FText EmptyText = FText::GetEmpty(); + TestFalse("IsNotEmpty should return false for empty text", UDirectiveUtilTextFunctionLibrary::IsNotEmpty(EmptyText)); + + return true; +} diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Public/DirectiveUtilitiesTests.h b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Public/DirectiveUtilitiesTests.h new file mode 100644 index 00000000..0bd1adba --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Public/DirectiveUtilitiesTests.h @@ -0,0 +1,11 @@ +#pragma once + +#include "CoreMinimal.h" +#include "Modules/ModuleManager.h" + +class FDirectiveUtilitiesTestsModule : public IModuleInterface +{ +public: + virtual void StartupModule() override; + virtual void ShutdownModule() override; +}; diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Public/Tests/DirectiveUtilTestObject.h b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Public/Tests/DirectiveUtilTestObject.h new file mode 100644 index 00000000..1954999b --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Public/Tests/DirectiveUtilTestObject.h @@ -0,0 +1,115 @@ +#pragma once + +#include "CoreMinimal.h" +#include "UObject/Object.h" +#include "GameFramework/SaveGame.h" +#include "Engine/HitResult.h" +#include "DirectiveUtilTestObject.generated.h" + +class UWorld; + +UCLASS() +class UDirectiveUtilTestObject : public UObject +{ + GENERATED_BODY() + +public: + UPROPERTY() + TArray TestArray; + + UPROPERTY() + TMap TestMap; + + UPROPERTY() + TMap TestMap2; + + UPROPERTY() + TMap TestStringKeyMap; +}; + +/** Concrete save-game subclass for tests (USaveGame itself is abstract and cannot be instantiated). */ +UCLASS() +class UDirectiveUtilTestSaveGame : public USaveGame +{ + GENERATED_BODY() + +public: + UPROPERTY() + int32 TestValue = 0; +}; + +/** + * Listener for exercising the async-task dynamic multicast delegates from automation tests. + * Dynamic delegates can only bind to UFUNCTIONs, so the handlers live on a UObject. The + * Keepalive property lets a rooted listener keep the async task itself alive across frames. + */ +UCLASS() +class UDirectiveUtilDelegateListener : public UObject +{ + GENERATED_BODY() + +public: + UPROPERTY() + bool bCompleted = false; + + UPROPERTY() + bool bFailed = false; + + UPROPERTY() + int32 CompletedCount = 0; + + /** Number of hits reported by the most recent trace completion. */ + UPROPERTY() + int32 HitCount = 0; + + /** Success flag from the most recent bool-payload completion (e.g. move-to-location). */ + UPROPERTY() + bool bLastSuccess = false; + + UPROPERTY() + TObjectPtr LastObject = nullptr; + + /** The object array from the most recent batch completion (e.g. async load assets). */ + UPROPERTY() + TArray> LastObjects; + + /** Holds a strong reference to the async task so it survives GC while the test waits. */ + UPROPERTY() + TObjectPtr Keepalive = nullptr; + + /** The transient world a latent delay scenario ticks and tears down when it settles. */ + UPROPERTY() + TObjectPtr ScenarioWorld = nullptr; + + /** Handler for parameterless completion delegates (e.g. the cancellable delay). */ + UFUNCTION() + void OnCompleted() { bCompleted = true; ++CompletedCount; } + + /** Handler for object-payload completion delegates (e.g. async load asset). */ + UFUNCTION() + void OnObjectCompleted(UObject* Object) { bCompleted = true; ++CompletedCount; LastObject = Object; } + + /** Handler for object-payload failure delegates. */ + UFUNCTION() + void OnObjectFailed(UObject* Object) { bFailed = true; } + + /** Handler for object-array-payload completion delegates (e.g. async load assets). */ + UFUNCTION() + void OnObjectsCompleted(const TArray& Objects) { bCompleted = true; ++CompletedCount; LastObjects.Reset(); LastObjects.Append(Objects); } + + /** Handler for class-payload completion delegates (e.g. async load class). */ + UFUNCTION() + void OnClassCompleted(UClass* Class) { bCompleted = true; ++CompletedCount; LastObject = Class; } + + /** Handler for class-payload failure delegates. */ + UFUNCTION() + void OnClassFailed(UClass* Class) { bFailed = true; } + + /** Handler for trace completion delegates. */ + UFUNCTION() + void OnTraceCompleted(const TArray& Hits) { bCompleted = true; ++CompletedCount; HitCount = Hits.Num(); } + + /** Handler for bool-payload completion delegates (e.g. move-to-location). */ + UFUNCTION() + void OnBoolCompleted(bool bSuccess) { bCompleted = true; ++CompletedCount; bLastSuccess = bSuccess; } +}; \ No newline at end of file diff --git a/Source/ProjectEleri/ProjectEleri.Build.cs b/Source/ProjectEleri/ProjectEleri.Build.cs index 084537c5..b4a86b95 100644 --- a/Source/ProjectEleri/ProjectEleri.Build.cs +++ b/Source/ProjectEleri/ProjectEleri.Build.cs @@ -29,7 +29,8 @@ public class ProjectEleri : ModuleRules "GameplayTasks", "UniversalCameraPlugin", "StateTreeModule", - "GameplayStateTreeModule" + "GameplayStateTreeModule", + "DirectiveUtilitiesRuntime" }); PrivateDependencyModuleNames.AddRange(new string[] { "OnlineSubsystem", "OnlineSubsystemUtils" });