diff --git a/Config/DefaultGame.ini b/Config/DefaultGame.ini index f22191a7..3aac9524 100644 --- a/Config/DefaultGame.ini +++ b/Config/DefaultGame.ini @@ -171,7 +171,6 @@ bMoviesAreSkippable=False [/Script/ProjectEleri.EleriGameSettings] SpecialItemsMap=(((TagName="Items.Gold"), "ItemDataAsset:DA_Gold"),((TagName="Items.Crystals.Air"), "ItemDataAsset:DA_CrystalAir"),((TagName="Items.Crystals.Earth"), "ItemDataAsset:DA_CrystalEarth"),((TagName="Items.Crystals.Fire"), "ItemDataAsset:DA_CrystalFire"),((TagName="Items.Crystals.Water"), "ItemDataAsset:DA_CrystalWater")) CameraZoomLevels=(((TagName="Camera.BoomOffset.Default"), (X=-1200.000000,Y=0.000000,Z=50.000000)),((TagName="Camera.BoomOffset.Cave"), (X=-800.000000,Y=0.000000,Z=40.000000)),((TagName="Camera.BoomOffset.Flying"), (X=-1500.000000,Y=0.000000,Z=60.000000))) -ExpIncreaseEffect=/Script/Engine.BlueprintGeneratedClass'/Game/Blueprints/GameplayAbilities/GameplayEffects/GE_AddExp_Template.GE_AddExp_Template_C' +ProfessionExpArray=100.000000 +ProfessionExpArray=500.000000 +ProfessionExpArray=1500.000000 @@ -192,4 +191,5 @@ ExpIncreaseEffect=/Script/Engine.BlueprintGeneratedClass'/Game/Blueprints/Gamepl +ProfessionExpArray=180000.000000 +ProfessionExpArray=208000.000000 +ProfessionExpArray=240000.000000 +ExpIncreaseEffectClass=/Script/Engine.BlueprintGeneratedClass'/Game/Blueprints/GameEffects/GSE_AddExp_Template.GSE_AddExp_Template_C' diff --git a/Content/Blueprints/GameEffects/GSE_AddExp_Template.uasset b/Content/Blueprints/GameEffects/GSE_AddExp_Template.uasset new file mode 100644 index 00000000..4112a6ca --- /dev/null +++ b/Content/Blueprints/GameEffects/GSE_AddExp_Template.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3969adff60bce48ac9b833a63499e23c345c93c8dc0e10b45e83524e627b3d93 +size 7853 diff --git a/Content/Blueprints/GameEffects/GSE_ModifyEnergyTemplate.uasset b/Content/Blueprints/GameEffects/GSE_ModifyEnergyTemplate.uasset new file mode 100644 index 00000000..ece9e4bd --- /dev/null +++ b/Content/Blueprints/GameEffects/GSE_ModifyEnergyTemplate.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6af7ba2a22d04e33bedac5771f1dae03f353ffe58df9c21507e774577246e2f8 +size 6682 diff --git a/Plugins/InteractionSystem/Content/Materials/M_UiBackgroundBlur.uasset b/Plugins/InteractionSystem/Content/Materials/M_UiBackgroundBlur.uasset new file mode 100644 index 00000000..26d14c22 --- /dev/null +++ b/Plugins/InteractionSystem/Content/Materials/M_UiBackgroundBlur.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:834573bf936248e156e5934a194a0e5ec127265056c109618249f87b47f37763 +size 11848 diff --git a/Plugins/InteractionSystem/Content/WBP_Interaction.uasset b/Plugins/InteractionSystem/Content/WBP_Interaction.uasset new file mode 100644 index 00000000..79de5f87 --- /dev/null +++ b/Plugins/InteractionSystem/Content/WBP_Interaction.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:808abfec1ce005725f63dc28ed9f7b2a71fff4d8d16a0c0f3d6a9fe78996658e +size 46657 diff --git a/Plugins/InteractionSystem/InteractionSystem.uplugin b/Plugins/InteractionSystem/InteractionSystem.uplugin new file mode 100644 index 00000000..23ee6672 --- /dev/null +++ b/Plugins/InteractionSystem/InteractionSystem.uplugin @@ -0,0 +1,24 @@ +{ + "FileVersion": 3, + "Version": 1, + "VersionName": "1.0", + "FriendlyName": "InteractionSystem", + "Description": "", + "Category": "Gameplay", + "CreatedBy": "", + "CreatedByURL": "", + "DocsURL": "", + "MarketplaceURL": "", + "SupportURL": "", + "CanContainContent": true, + "IsBetaVersion": false, + "IsExperimentalVersion": false, + "Installed": false, + "Modules": [ + { + "Name": "InteractionSystem", + "Type": "Runtime", + "LoadingPhase": "Default" + } + ] +} \ No newline at end of file diff --git a/Plugins/InteractionSystem/Resources/Icon128.png b/Plugins/InteractionSystem/Resources/Icon128.png new file mode 100644 index 00000000..26245f6a --- /dev/null +++ b/Plugins/InteractionSystem/Resources/Icon128.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f7239efaeefbd82de33ebe18518e50de075ea4188a468a9e4991396433d2275f +size 12699 diff --git a/Plugins/InteractionSystem/Source/InteractionSystem/InteractionSystem.Build.cs b/Plugins/InteractionSystem/Source/InteractionSystem/InteractionSystem.Build.cs new file mode 100644 index 00000000..6cf4ca85 --- /dev/null +++ b/Plugins/InteractionSystem/Source/InteractionSystem/InteractionSystem.Build.cs @@ -0,0 +1,53 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +using UnrealBuildTool; + +public class InteractionSystem : ModuleRules +{ + public InteractionSystem(ReadOnlyTargetRules Target) : base(Target) + { + PCHUsage = ModuleRules.PCHUsageMode.UseExplicitOrSharedPCHs; + + PublicIncludePaths.AddRange( + new string[] { + // ... add public include paths required here ... + } + ); + + + PrivateIncludePaths.AddRange( + new string[] { + // ... add other private include paths required here ... + } + ); + + + PublicDependencyModuleNames.AddRange( + new string[] + { + "Core", "UMG", + // ... add other public dependencies that you statically link with here ... + } + ); + + + PrivateDependencyModuleNames.AddRange( + new string[] + { + "CoreUObject", + "Engine", + "Slate", + "SlateCore", + "DeveloperSettings" + } + ); + + + DynamicallyLoadedModuleNames.AddRange( + new string[] + { + // ... add any modules that your module loads dynamically here ... + } + ); + } +} diff --git a/Plugins/InteractionSystem/Source/InteractionSystem/Private/Components/InteractionWidgetComponent.cpp b/Plugins/InteractionSystem/Source/InteractionSystem/Private/Components/InteractionWidgetComponent.cpp new file mode 100644 index 00000000..2c7726d2 --- /dev/null +++ b/Plugins/InteractionSystem/Source/InteractionSystem/Private/Components/InteractionWidgetComponent.cpp @@ -0,0 +1,53 @@ +// Fill out your copyright notice in the Description page of Project Settings. + + +#include "Components/InteractionWidgetComponent.h" +#include "InteractionSettings.h" +#include "Interface/InteractableActorInterface.h" +#include "Widgets/BaseInteractionWidget.h" + + +UInteractionWidgetComponent::UInteractionWidgetComponent() +{ + PrimaryComponentTick.bCanEverTick = true; + + Space = EWidgetSpace::Screen; + bManuallyRedraw = true; + RedrawTime = 1.f; + bDrawAtDesiredSize = true; + Pivot = FVector2D(0.5f, 0.5f); + TickMode = ETickMode::Disabled; + if (const UInteractionSettings* InteractionSettings = GetDefault()) + { + WidgetClass = InteractionSettings->DefaultInteractionWidgetClass; + } +} + +void UInteractionWidgetComponent::ToggleWidget(bool bActive) +{ + if (bActive) + { + if (UBaseInteractionWidget* InteractionWidget = Cast(GetWidget())) + { + InteractionWidget->UpdateInfo(IInteractableActorInterface::Execute_GetInteractionText(GetOwner())); + } + } + + SetVisibility(bActive); + RequestRenderUpdate(); +} + +void UInteractionWidgetComponent::BeginPlay() +{ + Super::BeginPlay(); + + if (UBaseInteractionWidget* InteractionWidget = Cast(GetWidget())) + { + InteractionWidget->UpdateInfo(IInteractableActorInterface::Execute_GetInteractionText(GetOwner())); + } + + SetPivot(GetPivot() + WidgetOffset); + ToggleWidget(false); +} + + diff --git a/Plugins/InteractionSystem/Source/InteractionSystem/Private/InteractionSystem.cpp b/Plugins/InteractionSystem/Source/InteractionSystem/Private/InteractionSystem.cpp new file mode 100644 index 00000000..ce69aa13 --- /dev/null +++ b/Plugins/InteractionSystem/Source/InteractionSystem/Private/InteractionSystem.cpp @@ -0,0 +1,20 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#include "InteractionSystem.h" + +#define LOCTEXT_NAMESPACE "FInteractionSystemModule" + +void FInteractionSystemModule::StartupModule() +{ + // This code will execute after your module is loaded into memory; the exact timing is specified in the .uplugin file per-module +} + +void FInteractionSystemModule::ShutdownModule() +{ + // This function may be called during shutdown to clean up your module. For modules that support dynamic reloading, + // we call this function before unloading the module. +} + +#undef LOCTEXT_NAMESPACE + +IMPLEMENT_MODULE(FInteractionSystemModule, InteractionSystem) \ No newline at end of file diff --git a/Plugins/InteractionSystem/Source/InteractionSystem/Private/Subsystem/InteractionSubsystem.cpp b/Plugins/InteractionSystem/Source/InteractionSystem/Private/Subsystem/InteractionSubsystem.cpp new file mode 100644 index 00000000..4cdb3dc5 --- /dev/null +++ b/Plugins/InteractionSystem/Source/InteractionSystem/Private/Subsystem/InteractionSubsystem.cpp @@ -0,0 +1,180 @@ +// Fill out your copyright notice in the Description page of Project Settings. + + +#include "Subsystem/InteractionSubsystem.h" +#include "EngineUtils.h" +#include "InteractionSettings.h" +#include "GameFramework/Character.h" +#include "Interface/InteractableActorInterface.h" + +void UInteractionSubsystem::Initialize(FSubsystemCollectionBase& Collection) +{ + Super::Initialize(Collection); + + if (GetWorld()) + { + GetWorld()->AddOnActorSpawnedHandler(FOnActorSpawned::FDelegate::CreateUObject(this, &UInteractionSubsystem::RegisterInteractableActor)); + GetWorld()->AddOnActorDestroyedHandler(FOnActorDestroyed::FDelegate::CreateUObject(this, &UInteractionSubsystem::UnregisterInteractableActor)); + } +} + +void UInteractionSubsystem::Deinitialize() +{ + InteractableActorList.Empty(); + + if (UInteractionSubsystem* IS = GetWorld()->GetSubsystem()) + { + if (IS->CurrentlyInteractableObject.IsValid()) + { + IInteractableActorInterface::Execute_ToggleInteractableUI(IS->CurrentlyInteractableObject.Get(), false); + } + IS->CurrentlyInteractableObject = nullptr; + } + + Super::Deinitialize(); +} + +bool UInteractionSubsystem::ShouldCreateSubsystem(UObject* Outer) const +{ + if (!Super::ShouldCreateSubsystem(Outer)) return false; + + if (!Outer || !Outer->GetWorld()) return false; + + UWorld* World = Outer->GetWorld(); + return World->WorldType == EWorldType::Type::Game || World->WorldType == EWorldType::Type::PIE; +} + +void UInteractionSubsystem::OnWorldBeginPlay(UWorld& InWorld) +{ + Super::OnWorldBeginPlay(InWorld); + + RescanWorld(); +} + +void UInteractionSubsystem::Tick(float DeltaTime) +{ + GetClosestInteractable(this); +} + +AActor* UInteractionSubsystem::GetClosestInteractable(const UObject* WorldContext) +{ + AActor* Closest = nullptr; + + if (!IsValid(WorldContext) || !IsValid(WorldContext->GetWorld())) + { + return Closest; + } + + UInteractionSubsystem* IS = WorldContext->GetWorld()->GetSubsystem(); + ensure(IS); + + const APlayerController* PC = WorldContext->GetWorld()->GetFirstPlayerController(); + if (!PC) return Closest; + + const ACharacter* PlayerCharacter = Cast(PC->GetPawn()); + if (!PlayerCharacter) return Closest; + + const UInteractionSettings* InteractionSettings = GetDefault(); + ensure(InteractionSettings); + + float ClosestDist = 9999999.f; + for (TWeakObjectPtr Actor : IS->InteractableActorList) + { + if (!Actor.IsValid()) + continue; + + const float Dist = FVector::DistSquared(Actor->GetActorLocation(), PlayerCharacter->GetActorLocation()); + const float MaxInteractionDistance = IInteractableActorInterface::Execute_GetInteractionDistance(Actor.Get()); + + if (FMath::Pow(MaxInteractionDistance, 2) < Dist || Dist > ClosestDist) + continue; + + FVector2D ScreenLoc; + if (!PC->ProjectWorldLocationToScreen(Actor->GetActorLocation(), ScreenLoc)) + { + continue; + } + + if (!IInteractableActorInterface::Execute_IsInteractable(Actor.Get())) + { + continue; + } + + ClosestDist = Dist; + Closest = Actor.Get(); + } + + if (IS->CurrentlyInteractableObject != Closest) + { + if (IS->CurrentlyInteractableObject.IsValid()) + { + // UE_LOG(LogTemp, Warning, TEXT("Turning off last interactable: %s"), *IS->CurrentlyInteractableObject.Get()->GetActorNameOrLabel()) + IInteractableActorInterface::Execute_ToggleInteractableUI(IS->CurrentlyInteractableObject.Get(), false); + } + if (Closest) + { + // UE_LOG(LogTemp, Warning, TEXT("Turning ON interactable: %s"), *Closest->GetActorNameOrLabel()) + IInteractableActorInterface::Execute_ToggleInteractableUI(Closest, true); + } + IS->CurrentlyInteractableObject = Closest; + } + + return Closest; +} + +void UInteractionSubsystem::UpdateInteractableText(AActor* Interactable) +{ + if (!Interactable) return; + + UInteractionSubsystem* InteractionSubsystem = Interactable->GetWorld()->GetSubsystem(); + if (!InteractionSubsystem) return; + + if (InteractionSubsystem->CurrentlyInteractableObject != Interactable) return; + + IInteractableActorInterface::Execute_ToggleInteractableUI(InteractionSubsystem->CurrentlyInteractableObject.Get(), true); +} + +void UInteractionSubsystem::RescanWorld() +{ + UWorld* World = GetWorld(); + int32 Count = 0; + + if (!World) + return; + + // Prune stuff + InteractableActorList.RemoveAllSwap( + [](TWeakObjectPtr Actor) + { + return !Actor.IsValid(); + } + ); + + for (TActorIterator It(World); It; ++It) + { + Count++; + AActor* Actor = *It; + if (IsValid(Actor) && Actor->Implements()) + { + RegisterInteractableActor(Actor); + } + } +} + +void UInteractionSubsystem::RegisterInteractableActor(AActor* InteractableActor) +{ + if (IsValid(InteractableActor) && InteractableActor->Implements()) + { + InteractableActorList.Add(InteractableActor); + } +} + +void UInteractionSubsystem::UnregisterInteractableActor(AActor* InteractableActor) +{ + InteractableActorList.RemoveAllSwap( + [InteractableActor](TWeakObjectPtr Actor) + { + return !Actor.IsValid() || Actor.Get() == InteractableActor; + } + ); +} diff --git a/Plugins/InteractionSystem/Source/InteractionSystem/Private/Widgets/BaseInteractionWidget.cpp b/Plugins/InteractionSystem/Source/InteractionSystem/Private/Widgets/BaseInteractionWidget.cpp new file mode 100644 index 00000000..88b94893 --- /dev/null +++ b/Plugins/InteractionSystem/Source/InteractionSystem/Private/Widgets/BaseInteractionWidget.cpp @@ -0,0 +1,4 @@ +// Fill out your copyright notice in the Description page of Project Settings. + + +#include "Widgets/BaseInteractionWidget.h" diff --git a/Plugins/InteractionSystem/Source/InteractionSystem/Public/Components/InteractionWidgetComponent.h b/Plugins/InteractionSystem/Source/InteractionSystem/Public/Components/InteractionWidgetComponent.h new file mode 100644 index 00000000..4953d4e6 --- /dev/null +++ b/Plugins/InteractionSystem/Source/InteractionSystem/Public/Components/InteractionWidgetComponent.h @@ -0,0 +1,28 @@ +// Fill out your copyright notice in the Description page of Project Settings. + +#pragma once + +#include "CoreMinimal.h" +#include "Components/ActorComponent.h" +#include "Components/WidgetComponent.h" +#include "InteractionWidgetComponent.generated.h" + + +UCLASS(ClassGroup=(Custom), meta=(BlueprintSpawnableComponent)) +class INTERACTIONSYSTEM_API UInteractionWidgetComponent : public UWidgetComponent +{ + GENERATED_BODY() + +public: + UInteractionWidgetComponent(); + + UFUNCTION(BlueprintCallable, Category = "Interaction Widget Component") + void ToggleWidget(bool bActive); + + UPROPERTY(EditAnywhere, Category = "Interaction Widget Component") + FVector2D WidgetOffset; + +protected: + virtual void BeginPlay() override; + +}; diff --git a/Plugins/InteractionSystem/Source/InteractionSystem/Public/InteractionSettings.h b/Plugins/InteractionSystem/Source/InteractionSystem/Public/InteractionSettings.h new file mode 100644 index 00000000..8e274e01 --- /dev/null +++ b/Plugins/InteractionSystem/Source/InteractionSystem/Public/InteractionSettings.h @@ -0,0 +1,31 @@ +// Fill out your copyright notice in the Description page of Project Settings. + +#pragma once + +#include "CoreMinimal.h" +#include "Engine/DeveloperSettings.h" +#include "InteractionSettings.generated.h" + +class UBaseInteractionWidget; + +/** + * + */ +UCLASS(Config=Game, DefaultConfig, meta=(DisplayName="Interaction System")) +class INTERACTIONSYSTEM_API UInteractionSettings : public UDeveloperSettings +{ + GENERATED_BODY() + +public: + + // Max distance at which interactable will be considered active + UPROPERTY(Config, EditAnywhere) + float InteractableMaxDistance = 1000.f; + + // Angle from the camera view towards object to consider "in view" + UPROPERTY(Config, EditAnywhere) + float InteractableCameraViewAngle = 60.f; + + UPROPERTY(Config, EditAnywhere) + TSubclassOf DefaultInteractionWidgetClass; +}; diff --git a/Plugins/InteractionSystem/Source/InteractionSystem/Public/InteractionSystem.h b/Plugins/InteractionSystem/Source/InteractionSystem/Public/InteractionSystem.h new file mode 100644 index 00000000..3d187438 --- /dev/null +++ b/Plugins/InteractionSystem/Source/InteractionSystem/Public/InteractionSystem.h @@ -0,0 +1,14 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#pragma once + +#include "Modules/ModuleManager.h" + +class FInteractionSystemModule : public IModuleInterface +{ +public: + + /** IModuleInterface implementation */ + virtual void StartupModule() override; + virtual void ShutdownModule() override; +}; diff --git a/Plugins/InteractionSystem/Source/InteractionSystem/Public/Interface/InteractableActorInterface.h b/Plugins/InteractionSystem/Source/InteractionSystem/Public/Interface/InteractableActorInterface.h new file mode 100644 index 00000000..f66be44c --- /dev/null +++ b/Plugins/InteractionSystem/Source/InteractionSystem/Public/Interface/InteractableActorInterface.h @@ -0,0 +1,47 @@ +// Fill out your copyright notice in the Description page of Project Settings. + +#pragma once + +#include "CoreMinimal.h" +#include "UObject/Interface.h" +#include "InteractionSettings.h" +#include "InteractableActorInterface.generated.h" + +UINTERFACE(MinimalAPI) +class UInteractableActorInterface : public UInterface +{ + GENERATED_BODY() +}; + +/** + * + */ +class INTERACTIONSYSTEM_API IInteractableActorInterface +{ + GENERATED_BODY() + +public: + + UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "Interactable") + FText GetInteractionText() const; + virtual FText GetInteractionText_Implementation() const { return FText::GetEmpty(); } + + UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "Interactable") + bool IsInteractable() const; + virtual bool IsInteractable_Implementation() const { return false; } + + UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "Interactable") + void Interact(AActor* InteractionInstigator); + virtual void Interact_Implementation(AActor* InteractionInstigator) {} + + UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "Interactable") + void ToggleInteractableUI(bool bActive); + virtual void ToggleInteractableUI_Implementation(bool bActive) {} + + UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "Interactable") + float GetInteractionDistance() const; + virtual float GetInteractionDistance_Implementation() const + { + return GetDefault()->InteractableMaxDistance; + } +}; diff --git a/Plugins/InteractionSystem/Source/InteractionSystem/Public/Subsystem/InteractionSubsystem.h b/Plugins/InteractionSystem/Source/InteractionSystem/Public/Subsystem/InteractionSubsystem.h new file mode 100644 index 00000000..4879132b --- /dev/null +++ b/Plugins/InteractionSystem/Source/InteractionSystem/Public/Subsystem/InteractionSubsystem.h @@ -0,0 +1,48 @@ +// Fill out your copyright notice in the Description page of Project Settings. + +#pragma once + +#include "CoreMinimal.h" +#include "Subsystems/WorldSubsystem.h" +#include "Tickable.h" +#include "Stats/Stats.h" + +#include "InteractionSubsystem.generated.h" + +/** + * + */ +UCLASS() +class INTERACTIONSYSTEM_API UInteractionSubsystem : public UWorldSubsystem, public FTickableGameObject +{ + GENERATED_BODY() + +public: + + virtual void Initialize(FSubsystemCollectionBase& Collection) override; + virtual void Deinitialize() override; + virtual bool ShouldCreateSubsystem(UObject* Outer) const override; + virtual void OnWorldBeginPlay(UWorld& InWorld) override; + virtual bool IsTickable() const override { return !IsTemplate(); } + virtual void Tick(float DeltaTime) override; + virtual TStatId GetStatId() const override { RETURN_QUICK_DECLARE_CYCLE_STAT(UInteractionSubsystem, STATGROUP_Tickables); } + + static AActor* GetClosestInteractable(const UObject* WorldContext); + + UFUNCTION(BlueprintCallable, Category = "Interactable") + static void UpdateInteractableText(AActor* Interactable); + +protected: + + UFUNCTION() + void RescanWorld(); + + void RegisterInteractableActor(AActor* InteractableActor); + void UnregisterInteractableActor(AActor* InteractableActor); + + UPROPERTY() + TArray> InteractableActorList; + + UPROPERTY() + TWeakObjectPtr CurrentlyInteractableObject; +}; diff --git a/Plugins/InteractionSystem/Source/InteractionSystem/Public/Widgets/BaseInteractionWidget.h b/Plugins/InteractionSystem/Source/InteractionSystem/Public/Widgets/BaseInteractionWidget.h new file mode 100644 index 00000000..69c0650a --- /dev/null +++ b/Plugins/InteractionSystem/Source/InteractionSystem/Public/Widgets/BaseInteractionWidget.h @@ -0,0 +1,22 @@ +// Fill out your copyright notice in the Description page of Project Settings. + +#pragma once + +#include "CoreMinimal.h" +#include "Blueprint/UserWidget.h" +#include "BaseInteractionWidget.generated.h" + +/** + * + */ +UCLASS() +class INTERACTIONSYSTEM_API UBaseInteractionWidget : public UUserWidget +{ + GENERATED_BODY() + +public: + + UFUNCTION(BlueprintCallable, BlueprintImplementableEvent, Category = "Interaction System") + void UpdateInfo(const FText& InteractionText); + +}; diff --git a/Plugins/NoiseMapGenerator/NoiseMapGenerator.uplugin b/Plugins/NoiseMapGenerator/NoiseMapGenerator.uplugin new file mode 100644 index 00000000..23270f5c --- /dev/null +++ b/Plugins/NoiseMapGenerator/NoiseMapGenerator.uplugin @@ -0,0 +1,27 @@ +{ + "FileVersion": 3, + "Version": 1, + "VersionName": "1.0.0", + "FriendlyName": "Noise Map Generator", + "Description": "Bake tiling noise textures with live preview.", + "Category": "Editor", + "CreatedBy": "UNmisterIZE(Sebastien Durocher)", + "CreatedByURL": "", + "DocsURL": "https://drive.google.com/file/d/134xAHp1LABTME2vNDhH0gcCkiqj1sL-o/view?usp=sharing", + "MarketplaceURL": "com.epicgames.launcher://ue/Fab/product/2fd402da-2ee0-4532-b219-a0703790151d", + "SupportURL": "suroduro7@gmail.com", + "CanContainContent": true, + "Installed": true, + "Modules": [ + { + "Name": "NoiseMapGenerator", + "Type": "Editor", + "LoadingPhase": "Default", + "PlatformAllowList": [ + "Win64", + "Mac", + "Linux" + ] + } + ] +} \ No newline at end of file diff --git a/Plugins/NoiseMapGenerator/Resources/Icon128.png b/Plugins/NoiseMapGenerator/Resources/Icon128.png new file mode 100644 index 00000000..3ba86204 --- /dev/null +++ b/Plugins/NoiseMapGenerator/Resources/Icon128.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e641c7eef843241e99886fd70553b0857fc7c0f5e95d888f1fa0ede3549dfe19 +size 32924 diff --git a/Plugins/NoiseMapGenerator/Source/NoiseMapGenerator/NoiseMapGenerator.build.cs b/Plugins/NoiseMapGenerator/Source/NoiseMapGenerator/NoiseMapGenerator.build.cs new file mode 100644 index 00000000..e904a885 --- /dev/null +++ b/Plugins/NoiseMapGenerator/Source/NoiseMapGenerator/NoiseMapGenerator.build.cs @@ -0,0 +1,24 @@ +/* Copyright UNmisterIZE(Sebastien Durocher) 2025 All Rights Reserved. +Project: NoiseMapGenerator Plugin */ + +using UnrealBuildTool; + +public class NoiseMapGenerator : ModuleRules +{ + public NoiseMapGenerator(ReadOnlyTargetRules Target) : base(Target) + { + PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs; + + PublicDependencyModuleNames.AddRange(new string[] + { + "Core", "CoreUObject", "Engine" + }); + + PrivateDependencyModuleNames.AddRange(new string[] + { + "Slate", "SlateCore", "EditorStyle", "UnrealEd", + "Projects", "InputCore", "ToolMenus", "AssetTools", + "AssetRegistry", "RenderCore", "RHI", "DeveloperSettings" + }); + } +} \ No newline at end of file diff --git a/Plugins/NoiseMapGenerator/Source/NoiseMapGenerator/Private/NoiseMapGenerator.cpp b/Plugins/NoiseMapGenerator/Source/NoiseMapGenerator/Private/NoiseMapGenerator.cpp new file mode 100644 index 00000000..aef05de1 --- /dev/null +++ b/Plugins/NoiseMapGenerator/Source/NoiseMapGenerator/Private/NoiseMapGenerator.cpp @@ -0,0 +1,508 @@ +/* Copyright UNmisterIZE(Sebastien Durocher) 2025 All Rights Reserved. + +Project: NoiseMapGenerator Plugin +File: NoiseMapGenerator.cpp +*/ + +#include "NoiseMapGenerator.h" +#include "Engine/Texture2D.h" +#include "AssetRegistry/AssetRegistryModule.h" +#include "Misc/PackageName.h" +#include "UObject/Package.h" +#include "UObject/SavePackage.h" + +////// --------------------------------------------------------- +namespace TileableNoise +{ + static FORCEINLINE float Fade(float t){ return t*t*t*(t*(t*6 - 15) + 10); } + static FORCEINLINE float Lerp(float a,float b,float t){ return a + t*(b - a); } + + // Permutation table (512 entries = 256 duplicated) + struct Perm + { + TArray P; + explicit Perm(int32 Seed) + { + P.SetNumUninitialized(512); + TArray base; base.SetNumUninitialized(256); + for (int32 i=0;i<256;++i) base[i]=i; + FRandomStream RNG(Seed); + for (int32 i=255;i>0;--i){ const int32 j=RNG.RandRange(0,i); Swap(base[i],base[j]); } + for (int32 i=0;i<512;++i) P[i]=base[i & 255]; + } + FORCEINLINE int32 H4(int32 x,int32 y,int32 z,int32 w) const + { return P[P[P[P[x & 255] + (y & 255)] + (z & 255)] + (w & 255)]; } + }; + + // 4D gradient (32 dirs) + static FORCEINLINE float Grad4(int32 h, float x,float y,float z,float w) + { + const int32 b = h & 31; + switch (b) + { + case 0: return x + y + z + w; case 1: return x + y + z - w; + case 2: return x + y - z + w; case 3: return x + y - z - w; + case 4: return x - y + z + w; case 5: return x - y + z - w; + case 6: return x - y - z + w; case 7: return x - y - z - w; + case 8: return -x + y + z + w; case 9: return -x + y + z - w; + case 10: return -x + y - z + w; case 11: return -x + y - z - w; + case 12: return -x - y + z + w; case 13: return -x - y + z - w; + case 14: return -x - y - z + w; case 15: return -x - y - z - w; + case 16: return x + y + z; case 17: return x + y - z; + case 18: return x - y + z; case 19: return x - y - z; + case 20: return -x + y + z; case 21: return -x + y - z; + case 22: return -x - y + z; case 23: return -x - y - z; + case 24: return x + z + w; case 25: return x + z - w; + case 26: return x - z + w; case 27: return x - z - w; + case 28: return -x + z + w; case 29: return -x + z - w; + case 30: return -x - z + w; default: return -x - z - w; + } + } + + static float Perlin4D(float x,float y,float z,float w,const Perm& perm) + { + const int32 X0 = FMath::FloorToInt(x), Y0 = FMath::FloorToInt(y), Z0 = FMath::FloorToInt(z), W0 = FMath::FloorToInt(w); + const float xf = x - X0, yf = y - Y0, zf = z - Z0, wf = w - W0; + const float u = Fade(xf), v = Fade(yf), s = Fade(zf), t = Fade(wf); + const int32 X1=X0+1,Y1=Y0+1,Z1=Z0+1,W1=W0+1; + + const float n0000 = Grad4(perm.H4(X0,Y0,Z0,W0), xf, yf, zf, wf); + const float n1000 = Grad4(perm.H4(X1,Y0,Z0,W0), xf-1.f, yf, zf, wf); + const float n0100 = Grad4(perm.H4(X0,Y1,Z0,W0), xf, yf-1.f, zf, wf); + const float n1100 = Grad4(perm.H4(X1,Y1,Z0,W0), xf-1.f, yf-1.f, zf, wf); + const float n0010 = Grad4(perm.H4(X0,Y0,Z1,W0), xf, yf, zf-1.f, wf); + const float n1010 = Grad4(perm.H4(X1,Y0,Z1,W0), xf-1.f, yf, zf-1.f, wf); + const float n0110 = Grad4(perm.H4(X0,Y1,Z1,W0), xf, yf-1.f, zf-1.f, wf); + const float n1110 = Grad4(perm.H4(X1,Y1,Z1,W0), xf-1.f, yf-1.f, zf-1.f, wf); + + const float n0001 = Grad4(perm.H4(X0,Y0,Z0,W1), xf, yf, zf, wf-1.f); + const float n1001 = Grad4(perm.H4(X1,Y0,Z0,W1), xf-1.f, yf, zf, wf-1.f); + const float n0101 = Grad4(perm.H4(X0,Y1,Z0,W1), xf, yf-1.f, zf, wf-1.f); + const float n1101 = Grad4(perm.H4(X1,Y1,Z0,W1), xf-1.f, yf-1.f, zf, wf-1.f); + const float n0011 = Grad4(perm.H4(X0,Y0,Z1,W1), xf, yf, zf-1.f, wf-1.f); + const float n1011 = Grad4(perm.H4(X1,Y0,Z1,W1), xf-1.f, yf, zf-1.f, wf-1.f); + const float n0111 = Grad4(perm.H4(X0,Y1,Z1,W1), xf, yf-1.f, zf-1.f, wf-1.f); + const float n1111 = Grad4(perm.H4(X1,Y1,Z1,W1), xf-1.f, yf-1.f, zf-1.f, wf-1.f); + + const float nx00 = Lerp(n0000, n1000, u); + const float nx10 = Lerp(n0100, n1100, u); + const float nx01 = Lerp(n0010, n1010, u); + const float nx11 = Lerp(n0110, n1110, u); + const float nxy0 = Lerp(nx00, nx10, v); + const float nxy1 = Lerp(nx01, nx11, v); + const float nxyz0 = Lerp(nxy0, nxy1, s); + + const float nx00b = Lerp(n0001, n1001, u); + const float nx10b = Lerp(n0101, n1101, u); + const float nx01b = Lerp(n0011, n1011, u); + const float nx11b = Lerp(n0111, n1111, u); + const float nxy0b = Lerp(nx00b, nx10b, v); + const float nxy1b = Lerp(nx01b, nx11b, v); + const float nxyz1 = Lerp(nxy0b, nxy1b, s); + + return Lerp(nxyz0, nxyz1, t); // [-1,1] + } + + static float FBM4D(float x,float y,float z,float w,const Perm& perm,int32 Octaves,float Lac,float Gain) + { + float amp=1.f,sum=0.f,range=0.f; + float X=x,Y=y,Z=z,W=w; + for (int32 o=0;o0.f)? sum/range : 0.f; + } + + // Hash utilities for Worley + static FORCEINLINE uint32 Hash2D(int32 x,int32 y,uint32 seed) + { + uint32 h = (uint32)x * 0x27d4eb2d ^ (uint32)y * 0x165667b1u ^ seed*0x9e3779b9u; + h ^= (h >> 16); h *= 0x7feb352d; h ^= (h >> 15); h *= 0x846ca68b; h ^= (h >> 16); + return h; + } + static FORCEINLINE float Frand01(uint32& state) + { + state ^= state << 13; state ^= state >> 17; state ^= state << 5; + return (state & 0x00FFFFFF) / 16777216.0f; + } + + // Torus-safe distance between (u,v) and feature in neighbor cell (iu,iv) with wrap + static float WorleyF1_Torus(float u,float v,int32 CellsU,int32 CellsV,uint32 seed) + { + // Base integer cell + const float fu = u * CellsU; + const float fv = v * CellsV; + const int32 iu0 = FMath::FloorToInt(fu); + const int32 iv0 = FMath::FloorToInt(fv); + + float best = 1e9f; + + for (int du=-1; du<=1; ++du) + for (int dv=-1; dv<=1; ++dv) + { + const int32 iu = (iu0 + du + CellsU) % CellsU; + const int32 iv = (iv0 + dv + CellsV) % CellsV; + + uint32 st = Hash2D(iu, iv, seed); + const float jitterX = Frand01(st); + const float jitterY = Frand01(st); + + const float fx = (float(iu) + jitterX) / float(CellsU); + const float fy = (float(iv) + jitterY) / float(CellsV); + + // torus wrap distance in [0,1] + float dx = FMath::Abs(u - fx); dx = FMath::Min(dx, 1.0f - dx); + float dy = FMath::Abs(v - fy); dy = FMath::Min(dy, 1.0f - dy); + + best = FMath::Min(best, FMath::Sqrt(dx*dx + dy*dy)); + } + return best; // [0..~0.5] + } + +} +/////// --------------------------------------------------------- + +static void MakeUniqueAssetPath(const FString& Folder, const FString& BaseName, bool bOverwrite, FString& OutPkg, FString& OutAsset) +{ + FString CleanFolder = Folder; + if (!CleanFolder.StartsWith(TEXT("/"))) CleanFolder = TEXT("/") + CleanFolder; + if (!CleanFolder.StartsWith(TEXT("/Game"))) CleanFolder = TEXT("/Game") + CleanFolder; + + FString CandidateName = BaseName; + FString CandidatePkg = CleanFolder / CandidateName; + + if (bOverwrite) + { + OutPkg = CandidatePkg; + OutAsset = CandidateName; + return; + } + + int32 Suffix = 1; + while (FPackageName::DoesPackageExist(CandidatePkg)) + { + CandidateName = FString::Printf(TEXT("%s_%03d"), *BaseName, Suffix++); + CandidatePkg = CleanFolder / CandidateName; + } + OutPkg = CandidatePkg; + OutAsset = CandidateName; +} + + +bool FNoiseMapGenerator::GeneratePixels(const FNoiseBakeSettings& S, int32 RepeatsUV, TArray& OutPixels, int32& OutW, int32& OutH) +{ + // --- size --- + const int32 W = S.Width; + const int32 H = S.Height; + if (W <= 0 || H <= 0) + { + OutW = OutH = 0; + OutPixels.Reset(); + return false; + } + OutW = W; + OutH = H; + OutPixels.SetNumUninitialized(W * H); + + // --- common setup --- + const float invW = 1.f / float(W); + const float invH = 1.f / float(H); + const float TwoPi = 6.28318530717958647692f; + const float Scale = 1.0f / FMath::Max(S.Frequency, 1e-6f); + + TileableNoise::Perm perm(S.Seed); + + // 4D torus mapping (period-1 in u,v) + auto AnglesFromUV = [&](float u, float v) + { + const float th = TwoPi * u; + const float ph = TwoPi * v; + struct { float X, Y, Z, W; } R + { + FMath::Cos(th) * Scale, + FMath::Sin(th) * Scale, + FMath::Cos(ph) * Scale, + FMath::Sin(ph) * Scale + }; + return R; + }; + + // Base sampler per algorithm, returns [-1,1] + auto SampleBase = [&](float u, float v) -> float + { + switch (S.Algorithm) + { + case ENoiseAlgorithm::Perlin: + { + const auto C = AnglesFromUV(u, v); + return (S.Mode == ENoiseMode::FBM) + ? TileableNoise::FBM4D(C.X, C.Y, C.Z, C.W, perm, S.Octaves, S.Lacunarity, S.Gain) + : TileableNoise::Perlin4D(C.X, C.Y, C.Z, C.W, perm); + } + + case ENoiseAlgorithm::RidgedFBM: + { + float amp = 0.5f; + float sum = 0.f; + float range = 0.f; + float freqMul = 1.f; + + for (int32 o = 0; o < S.Octaves; ++o) + { + const float ou = FMath::Frac(u * freqMul); + const float ov = FMath::Frac(v * freqMul); + const auto C = AnglesFromUV(ou, ov); + const float p = TileableNoise::Perlin4D(C.X, C.Y, C.Z, C.W, perm); // [-1,1] + const float r = 1.f - FMath::Abs(p); // [0,1] + + sum += amp * r; + range += amp; + + freqMul *= S.Lacunarity; + amp *= S.Gain; + } + + return (range > 0.f) ? (sum / range) * 2.f - 1.f : 0.f; + } + + case ENoiseAlgorithm::Worley: + { + const float cellsF = FMath::Clamp( + (1.0f / FMath::Max(S.Frequency, 1e-6f)) * 0.75f, + 1.f, 512.f + ); + + const int32 CellsU = FMath::Max(1, int32(FMath::RoundToInt(cellsF)) * RepeatsUV); + const int32 CellsV = FMath::Max(1, int32(FMath::RoundToInt(cellsF)) * RepeatsUV); + + const float d = TileableNoise::WorleyF1_Torus(u, v, CellsU, CellsV, (uint32)S.Seed); // [0..~0.5] + const float v01 = 1.0f - FMath::Clamp(d * 2.0f, 0.f, 1.f); + return v01 * 2.f - 1.f; + } + default: + return 0.f; + } + }; + + auto SampleNthOctave01 = [&](float u, float v, int32 OctIdx) -> float + { + switch (S.Algorithm) + { + case ENoiseAlgorithm::Perlin: + { + // Higher octave = higher frequency + const float freqMul = FMath::Pow(S.Lacunarity, OctIdx); + const auto C = AnglesFromUV(u * freqMul, v * freqMul); + const float p = TileableNoise::Perlin4D(C.X, C.Y, C.Z, C.W, perm); // [-1,1] + return 0.5f * (p + 1.f); // → [0,1] + } + + case ENoiseAlgorithm::RidgedFBM: + { + const float freqMul = FMath::Pow(S.Lacunarity, OctIdx); + const float ou = FMath::Frac(u * freqMul); + const float ov = FMath::Frac(v * freqMul); + const auto C = AnglesFromUV(ou, ov); + const float p = TileableNoise::Perlin4D(C.X, C.Y, C.Z, C.W, perm); // [-1,1] + const float r = 1.f - FMath::Abs(p); // [0,1] – "ridge" shape + return r; + } + + case ENoiseAlgorithm::Worley: + { + // More cells per octave for finer detail + const float cellsFBase = FMath::Clamp( + (1.0f / FMath::Max(S.Frequency, 1e-6f)) * 0.75f, + 1.f, 512.f + ); + const float octaveMul = FMath::Pow(S.Lacunarity, OctIdx); + const int32 CellsU = FMath::Max(1, int32(FMath::RoundToInt(cellsFBase * octaveMul)) * RepeatsUV); + const int32 CellsV = FMath::Max(1, int32(FMath::RoundToInt(cellsFBase * octaveMul)) * RepeatsUV); + + const float d = TileableNoise::WorleyF1_Torus(u, v, CellsU, CellsV, (uint32)S.Seed); + const float v01 = 1.0f - FMath::Clamp(d * 2.0f, 0.f, 1.f); // [0,1], "cells" as bright + return v01; + } + + default: + // Fallback mid-gray + return 0.5f; + } + }; + + // Write pixels + if (S.OutputMode == ENoiseOutputMode::Grayscale) + { + float minV = 1e9f; + float maxV = -1e9f; + TArray tmp; + tmp.SetNumUninitialized(W * H); + + int32 I = 0; + for (int32 y = 0; y < H; ++y) + { + for (int32 x = 0; x < W; ++x) + { + const float u = float(RepeatsUV) * float(x) * invW; + const float v = float(RepeatsUV) * float(y) * invH; + + const float vraw = SampleBase(u, v); // [-1,1] + tmp[I++] = vraw; + minV = FMath::Min(minV, vraw); + maxV = FMath::Max(maxV, vraw); + } + } + + const float invRange = + (S.bNormalize && maxV > minV) ? 1.f / (maxV - minV) : 0.5f; + + I = 0; + for (int32 y = 0; y < H; ++y) + { + for (int32 x = 0; x < W; ++x) + { + float v = tmp[I++]; + v = S.bNormalize + ? (v - minV) * invRange // [0,1] + : 0.5f * (v + 1.f); // [-1,1] → [0,1] + + const uint8 g = (uint8)FMath::Clamp( + FMath::RoundToInt(v * 255.f), 0, 255); + + OutPixels[y * W + x] = FColor(g, g, g, 255); + } + } + } + else // Split First 3 Octaves to RGB channles + { + for (int32 y = 0; y < H; ++y) + { + for (int32 x = 0; x < W; ++x) + { + const float u = float(RepeatsUV) * float(x) * invW; + const float v = float(RepeatsUV) * float(y) * invH; + + const float r = SampleNthOctave01(u, v, 0); + const float g = SampleNthOctave01(u, v, 1); + const float b = SampleNthOctave01(u, v, 2); + + OutPixels[y * W + x] = FColor( + (uint8)FMath::Clamp(FMath::RoundToInt(r * 255.f), 0, 255), + (uint8)FMath::Clamp(FMath::RoundToInt(g * 255.f), 0, 255), + (uint8)FMath::Clamp(FMath::RoundToInt(b * 255.f), 0, 255), + 255); + } + } + } + + return true; +} + +void FNoiseMapGenerator::BakeFromPixels( + const FNoiseBakeSettings& S, + const TArray& Pixels, + int32 W, + int32 H) +{ + if (W <= 0 || H <= 0 || Pixels.Num() != W * H) + { + return; + } + + // --- Resolve package path/name --- + FString OutPath = S.OutputPath.IsEmpty() + ? TEXT("/Game/Noise") + : S.OutputPath; + + if (!OutPath.StartsWith(TEXT("/Game"))) + { + OutPath = TEXT("/Game/Noise"); + } + + FString PackageName, AssetName; + MakeUniqueAssetPath(OutPath, S.BaseName, S.bOverwrite, PackageName, AssetName); + + UPackage* Package = CreatePackage(*PackageName); + if (!Package) + { + return; + } + + UTexture2D* Tex = NewObject( + Package, + *AssetName, + RF_Public | RF_Standalone + ); + if (!Tex) + { + return; + } + + Tex->Modify(); + + // Match preview flags + Tex->SRGB = false; + + if (S.OutputMode == ENoiseOutputMode::Grayscale) + { + Tex->CompressionSettings = TC_Grayscale; + } + else + { + Tex->CompressionSettings = TC_VectorDisplacementmap; // linear RGB + } + + Tex->CompressionNone = true; + Tex->DeferCompression = false; + Tex->MipGenSettings = TMGS_NoMipmaps; + Tex->AddressX = TA_Wrap; + Tex->AddressY = TA_Wrap; + Tex->LODGroup = TEXTUREGROUP_World; + + // Copy pixels into source mip + Tex->Source.Init(W, H, 1, 1, TSF_BGRA8); + { + uint8* Src = Tex->Source.LockMip(0); + const int64 Bytes = int64(W) * H * sizeof(FColor); + FMemory::Memcpy(Src, Pixels.GetData(), Bytes); + Tex->Source.UnlockMip(0); + } + + Tex->PostEditChange(); + Tex->UpdateResource(); + + FAssetRegistryModule::AssetCreated(Tex); + Package->MarkPackageDirty(); + + const FString PkgFile = + FPackageName::LongPackageNameToFilename( + PackageName, + FPackageName::GetAssetPackageExtension() + ); + + FSavePackageArgs Args; + Args.TopLevelFlags = RF_Public | RF_Standalone; + Args.SaveFlags = SAVE_None; + Args.Error = GError; + Args.bWarnOfLongFilename = true; + + UPackage::SavePackage(Package, Tex, *PkgFile, Args); +} + +void FNoiseMapGenerator::BakeNoiseTexture(const FNoiseBakeSettings& S) +{ + TArray Pixels; + int32 W = 0, H = 0; + + // IMPORTANT: RepeatsUV = 1 for a single seamless period + if (!GeneratePixels(S, /*RepeatsUV=*/1, Pixels, W, H)) + { + return; + } + + BakeFromPixels(S, Pixels, W, H); +} diff --git a/Plugins/NoiseMapGenerator/Source/NoiseMapGenerator/Private/NoiseMapGeneratorModule.cpp b/Plugins/NoiseMapGenerator/Source/NoiseMapGenerator/Private/NoiseMapGeneratorModule.cpp new file mode 100644 index 00000000..13be39b4 --- /dev/null +++ b/Plugins/NoiseMapGenerator/Source/NoiseMapGenerator/Private/NoiseMapGeneratorModule.cpp @@ -0,0 +1,416 @@ +/* Copyright UNmisterIZE(Sebastien Durocher) 2025 All Rights Reserved. + +Project: NoiseMapGenerator Plugin +File: NoiseMapGeneratorModule.cpp +*/ + +#include "NoiseMapGeneratorModule.h" +#include "NoiseMapGenerator.h" +#include "NoiseMapGeneratorSettings.h" +#include "Widgets/Docking/SDockTab.h" +#include "Widgets/SBoxPanel.h" +#include "Widgets/Layout/SBorder.h" +#include "Widgets/Input/SSpinBox.h" +#include "Widgets/Input/SCheckBox.h" +#include "Widgets/Input/SEditableTextBox.h" +#include "Widgets/Input/SButton.h" +#include "Widgets/Images/SImage.h" +#include "Widgets/Text/STextBlock.h" +#include "ToolMenus.h" +#include "LevelEditor.h" +#include "Engine/Texture2D.h" +#include "ContentBrowserModule.h" +#include "IContentBrowserSingleton.h" +#include "Modules/ModuleManager.h" + +static const FName NoiseMapGenTabName("NoiseMapGeneratorTab"); + +// Preview helper +static UTexture2D* CreateTransientTexture(int32 W, int32 H, const TArray& Pixels) +{ + if (Pixels.Num() != W * H) return nullptr; + UTexture2D* T = UTexture2D::CreateTransient(W, H, PF_B8G8R8A8); + if (!T) return nullptr; + T->SRGB = false; + T->CompressionSettings = TC_Default; + T->MipGenSettings = TMGS_NoMipmaps; + + FTexture2DMipMap& Mip = T->GetPlatformData()->Mips[0]; + void* Data = Mip.BulkData.Lock(LOCK_READ_WRITE); + const int64 Bytes = (int64)W * H * sizeof(FColor); + FMemory::Memcpy(Data, Pixels.GetData(), Bytes); + Mip.BulkData.Unlock(); + T->UpdateResource(); + return T; +} + +// Plugin UI setup +class SNoiseMapGeneratorWidget : public SCompoundWidget +{ +public: + SLATE_BEGIN_ARGS(SNoiseMapGeneratorWidget) {} + SLATE_END_ARGS() + + void Construct(const FArguments&) + { + Settings = GetMutableDefault(); + PreviewBrush = MakeShared(); + + ChildSlot +[ + SNew(SBorder) + [ + SNew(SSplitter) + + + SSplitter::Slot().Value(0.33f) + [ + SNew(SBorder) + [ + SNew(SBox) + .MinDesiredWidth(580.f) + [ + SNew(SVerticalBox) + + SVerticalBox::Slot().AutoHeight().Padding(6)[ SNew(STextBlock).Text(FText::FromString("Noise Settings")) ] + + + SVerticalBox::Slot().AutoHeight().Padding(6)[ MakeRowInt("Width", Settings->Width, 8, 8192, 64) ] + + SVerticalBox::Slot().AutoHeight().Padding(6)[ MakeRowInt("Height", Settings->Height, 8, 8192, 64) ] + + SVerticalBox::Slot().AutoHeight().Padding(6)[ MakeRowInt("Preview Tiling", Settings->TileRepeats, 1, 64, 1) ] + + SVerticalBox::Slot().AutoHeight().Padding(6)[ MakeRowInt("Seed", Settings->Seed, 0, INT_MAX, 1) ] + + SVerticalBox::Slot().AutoHeight().Padding(6)[ MakeRowInt("Octaves", Settings->Octaves, 1, 12, 1) ] + + SVerticalBox::Slot().AutoHeight().Padding(6)[ MakeRowFloat("Frequency", Settings->Frequency, 0.001f, 2.0f, 0.005f) ] + + SVerticalBox::Slot().AutoHeight().Padding(6)[ MakeRowFloat("Lacunarity", Settings->Lacunarity, 1.f, 8.f, 0.1f) ] + + SVerticalBox::Slot().AutoHeight().Padding(6)[ MakeRowFloat("Gain", Settings->Gain, 0.f, 1.f, 0.05f) ] + + SVerticalBox::Slot().AutoHeight().Padding(6)[ MakeRowBool("Normalize 0..1", Settings->bNormalize) ] + + + SVerticalBox::Slot().AutoHeight().Padding(6)[ MakeRowMode() ] + + SVerticalBox::Slot().AutoHeight().Padding(6)[ MakeRowOutputMode() ] + + SVerticalBox::Slot().AutoHeight().Padding(6)[ MakeRowAlgorithm() ] + + + SVerticalBox::Slot().AutoHeight().Padding(6)[ MakeRowText("Save Path (/Game/...)", Settings->OutputPath) ] + + SVerticalBox::Slot().AutoHeight().Padding(6)[ MakeRowText("Base Name", Settings->BaseName) ] + + SVerticalBox::Slot().AutoHeight().Padding(6)[ MakeRowBool("Overwrite Existing", Settings->bOverwrite) ] + // + SVerticalBox::Slot().AutoHeight().Padding(6)[ MakeRowBool("Generate Mips", Settings->bGenerateMips) ] + + + SVerticalBox::Slot().AutoHeight().Padding(6) + [ + SNew(SHorizontalBox) + + SHorizontalBox::Slot().AutoWidth().Padding(0,0,6,0) + [ SNew(SButton).OnClicked(this, &SNoiseMapGeneratorWidget::OnGenerate) + [ SNew(STextBlock).Text(FText::FromString("Generate Preview")) ] ] + + SHorizontalBox::Slot().AutoWidth().Padding(0,0,6,0) + [ SNew(SButton).OnClicked(this, &SNoiseMapGeneratorWidget::OnBake) + [ SNew(STextBlock).Text(FText::FromString("Bake & Save")) ] ] + + SHorizontalBox::Slot().AutoWidth() + [ SNew(SButton).OnClicked(this, &SNoiseMapGeneratorWidget::OnQuit) + [ SNew(STextBlock).Text(FText::FromString("Quit")) ] ] + ] + ] + ] + ] + + SSplitter::Slot().Value(0.67f) + [ + SNew(SBorder) + [ SAssignNew(PreviewImage, SImage).Image(nullptr) ] + ] + ] +]; + + Regenerate(); + } + +private: + // UI helpers + TSharedRef MakeRowInt(const char* Label, int32& V, int32 Min, int32 Max, int32 Step) + { + return SNew(SHorizontalBox) + + SHorizontalBox::Slot().AutoWidth().VAlign(VAlign_Center).Padding(0,0,8,0)[ SNew(STextBlock).Text(FText::FromString(Label)) ] + + SHorizontalBox::Slot().FillWidth(1.f) + [ SNew(SSpinBox).MinValue(Min).MaxValue(Max).Delta(Step) + .Value_Lambda([&V]{ return V; }) + .OnValueChanged_Lambda([&V](int32 NV){ V = NV; }) ]; + } + + TSharedRef MakeRowFloat(const char* Label, float& V, float Min, float Max, float Step) + { + return SNew(SHorizontalBox) + + SHorizontalBox::Slot().AutoWidth().VAlign(VAlign_Center).Padding(0,0,8,0)[ SNew(STextBlock).Text(FText::FromString(Label)) ] + + SHorizontalBox::Slot().FillWidth(1.f) + [ SNew(SSpinBox).MinValue(Min).MaxValue(Max).Delta(Step) + .Value_Lambda([&V]{ return V; }) + .OnValueChanged_Lambda([&V](float NV){ V = NV; }) ]; + } + + TSharedRef MakeRowBool(const char* Label, bool& B) + { + return SNew(SHorizontalBox) + + SHorizontalBox::Slot().AutoWidth().VAlign(VAlign_Center).Padding(0,0,8,0)[ SNew(STextBlock).Text(FText::FromString(Label)) ] + + SHorizontalBox::Slot().AutoWidth().VAlign(VAlign_Center) + [ SNew(SCheckBox) + .IsChecked_Lambda([&B]{ return B ? ECheckBoxState::Checked : ECheckBoxState::Unchecked; }) + .OnCheckStateChanged_Lambda([&B](ECheckBoxState S){ B = (S == ECheckBoxState::Checked); }) ]; + } + + TSharedRef MakeRowText(const char* Label, FString& T) + { + return SNew(SHorizontalBox) + + SHorizontalBox::Slot().AutoWidth().VAlign(VAlign_Center).Padding(0,0,8,0)[ SNew(STextBlock).Text(FText::FromString(Label)) ] + + SHorizontalBox::Slot().FillWidth(1.f) + [ SNew(SEditableTextBox) + .Text_Lambda([&T]{ return FText::FromString(T); }) + .OnTextCommitted_Lambda([&T](const FText& New, ETextCommit::Type){ T = New.ToString(); }) ]; + } + + TSharedRef MakeRowMode() + { + return SNew(SHorizontalBox) + + SHorizontalBox::Slot().AutoWidth().VAlign(VAlign_Center).Padding(0,0,8,0)[ SNew(STextBlock).Text(FText::FromString("Mode")) ] + + SHorizontalBox::Slot().AutoWidth().VAlign(VAlign_Center) + [ SNew(SCheckBox) + .OnCheckStateChanged_Lambda([this](ECheckBoxState S){ Settings->Mode = (S == ECheckBoxState::Checked) ? ENoiseMode::FBM : ENoiseMode::Perlin; Regenerate(); }) + .IsChecked_Lambda([this]{ return Settings->Mode == ENoiseMode::FBM ? ECheckBoxState::Checked : ECheckBoxState::Unchecked; }) + .Content()[ SNew(STextBlock).Text(FText::FromString("FBM (unchecked = Perlin)")) ] ]; + } + + TSharedRef MakeRowOutputMode() + { + return SNew(SHorizontalBox) + + SHorizontalBox::Slot().AutoWidth().VAlign(VAlign_Center).Padding(0,0,8,0)[ SNew(STextBlock).Text(FText::FromString("Output")) ] + + SHorizontalBox::Slot().AutoWidth().VAlign(VAlign_Center) + [ SNew(SCheckBox) + .OnCheckStateChanged_Lambda([this](ECheckBoxState S){ Settings->OutputMode = (S == ECheckBoxState::Checked) ? ENoiseOutputMode::SplitFirst3Octaves : ENoiseOutputMode::Grayscale; Regenerate(); }) + .IsChecked_Lambda([this]{ return Settings->OutputMode == ENoiseOutputMode::SplitFirst3Octaves ? ECheckBoxState::Checked : ECheckBoxState::Unchecked; }) + .Content()[ SNew(STextBlock).Text(FText::FromString("Split 3 Octaves to RGB")) ] ]; + } + + // --------------------------- + static FString AlgoToString(ENoiseAlgorithm A) + { + switch (A) + { + case ENoiseAlgorithm::Perlin: return TEXT("Perlin"); + case ENoiseAlgorithm::Worley: return TEXT("Worley (Cellular)"); + case ENoiseAlgorithm::RidgedFBM: return TEXT("Ridged FBM"); + default: return TEXT("Perlin"); + } + } + + TSharedRef OnGenerateAlgoWidget(TSharedPtr InItem) + { + return SNew(STextBlock).Text(FText::FromString(AlgoToString(*InItem))); + } + + void OnAlgoChanged(TSharedPtr NewSel, ESelectInfo::Type) + { + if (!NewSel.IsValid()) return; + Settings->Algorithm = *NewSel; + SelectedAlgo = NewSel; + Regenerate(); + } + + FText GetAlgoCurrentText() const + { + return FText::FromString(AlgoToString(Settings->Algorithm)); + } + + TSharedRef MakeRowAlgorithm() + { + // Populate items once if empty + if (AlgoItems.Num() == 0) + { + AlgoItems.Add(MakeShared(ENoiseAlgorithm::Perlin)); + AlgoItems.Add(MakeShared(ENoiseAlgorithm::Worley)); + AlgoItems.Add(MakeShared(ENoiseAlgorithm::RidgedFBM)); + + // pick current + for (const auto& It : AlgoItems) + { + if (*It == Settings->Algorithm) + { + SelectedAlgo = It; + break; + } + } + if (!SelectedAlgo.IsValid()) + { + SelectedAlgo = AlgoItems[0]; + } + } + + return SNew(SHorizontalBox) + + SHorizontalBox::Slot().AutoWidth().VAlign(VAlign_Center).Padding(0,0,8,0) + [ SNew(STextBlock).Text(FText::FromString("Algorithm")) ] + + SHorizontalBox::Slot().FillWidth(1.f) + [ + SNew(SComboBox>) + .OptionsSource(&AlgoItems) + .InitiallySelectedItem(SelectedAlgo) + .OnGenerateWidget(this, &SNoiseMapGeneratorWidget::OnGenerateAlgoWidget) + .OnSelectionChanged(this, &SNoiseMapGeneratorWidget::OnAlgoChanged) + [ + SNew(STextBlock).Text_Lambda([this]{ return GetAlgoCurrentText(); }) + ] + ]; + } + + TArray> AlgoItems; + TSharedPtr SelectedAlgo; + + FReply OnGenerate() + { + Regenerate(); + return FReply::Handled(); + } + + FReply OnBake() + { + FNoiseBakeSettings B; + B.Width = Settings->Width; + B.Height = Settings->Height; + B.TileRepeats = Settings->TileRepeats; + B.Seed = Settings->Seed; + B.Octaves = Settings->Octaves; + B.Frequency = Settings->Frequency; + B.Lacunarity = Settings->Lacunarity; + B.Gain = Settings->Gain; + B.bNormalize = Settings->bNormalize; + B.Mode = Settings->Mode; + B.OutputMode = Settings->OutputMode; + B.OutputPath = Settings->OutputPath; + B.BaseName = Settings->BaseName; + B.bOverwrite = Settings->bOverwrite; + //B.bGenerateMips = Settings->bGenerateMips; + B.Algorithm = Settings->Algorithm; + + // Calls GeneratePixels then bakes with the same flags as preview helper. + FNoiseMapGenerator::BakeNoiseTexture(B); + + // Open content browser at location + { + FString FolderPathStr = Settings->OutputPath; + if (!FolderPathStr.StartsWith(TEXT("/Game"))) + { + FolderPathStr = TEXT("/Game"); + } + + TArray Folders; + Folders.Add(FolderPathStr); + + IContentBrowserSingleton& CB = + FModuleManager::LoadModuleChecked("ContentBrowser").Get(); + CB.SyncBrowserToFolders(Folders); + } + + return FReply::Handled(); + } + + FReply OnQuit() + { + TSharedPtr Tab = FGlobalTabmanager::Get()->FindExistingLiveTab(NoiseMapGenTabName); + if (Tab.IsValid()) + { + Tab->RequestCloseTab(); + } + return FReply::Handled(); + } + + void Regenerate() + { + FNoiseBakeSettings B; + B.Width=Settings->Width; B.Height=Settings->Height; B.TileRepeats=Settings->TileRepeats; B.Seed=Settings->Seed; + B.Octaves=Settings->Octaves; B.Frequency=Settings->Frequency; B.Lacunarity=Settings->Lacunarity; B.Gain=Settings->Gain; + B.bNormalize=Settings->bNormalize; B.Mode=Settings->Mode; B.OutputMode=Settings->OutputMode; + B.OutputPath=Settings->OutputPath; B.BaseName=Settings->BaseName; B.bOverwrite=Settings->bOverwrite; //B.bGenerateMips=Settings->bGenerateMips; + B.Algorithm = Settings->Algorithm; + + TArray Pixels; int32 W=0, H=0; + if (FNoiseMapGenerator::GeneratePixels(B,Settings->TileRepeats, Pixels, W, H)) + { + if (UTexture2D* T = CreateTransientTexture(W, H, Pixels)) + { + if (!PreviewBrush.IsValid()) PreviewBrush = MakeShareable(new FSlateBrush()); + PreviewBrush->ImageSize = FVector2D(W, H); + PreviewBrush->SetResourceObject(T); + PreviewImage->SetImage(PreviewBrush.Get()); + } + } + }; + +private: + UNoiseMapGeneratorSettings* Settings = nullptr; + TSharedPtr PreviewImage; + TSharedPtr PreviewBrush; +}; + +void FNoiseMapGeneratorModule::StartupModule() +{ + if (FGlobalTabmanager::Get()->HasTabSpawner(NoiseMapGenTabName)) + { + FGlobalTabmanager::Get()->UnregisterNomadTabSpawner(NoiseMapGenTabName); + } + + FGlobalTabmanager::Get()->RegisterNomadTabSpawner( + NoiseMapGenTabName, + FOnSpawnTab::CreateLambda([](const FSpawnTabArgs&) + { + return SNew(SDockTab) + .TabRole(ETabRole::NomadTab) + [ + SNew(SNoiseMapGeneratorWidget) + ]; + }) + ) + .SetDisplayName(FText::FromString("Noise Map Generator")) + .SetMenuType(ETabSpawnerMenuType::Hidden); + + if (UToolMenus::IsToolMenuUIEnabled()) + { + RegisterMenus(); + } + else + { + UToolMenus::RegisterStartupCallback( + FSimpleMulticastDelegate::FDelegate::CreateRaw( + this, &FNoiseMapGeneratorModule::RegisterMenus)); + } +} + + +void FNoiseMapGeneratorModule::ShutdownModule() +{ + if (FGlobalTabmanager::Get()->HasTabSpawner(NoiseMapGenTabName)) + { + FGlobalTabmanager::Get()->UnregisterNomadTabSpawner(NoiseMapGenTabName); + } + UToolMenus::UnregisterOwner(this); +} + + +void FNoiseMapGeneratorModule::OpenWindow() +{ + FGlobalTabmanager::Get()->TryInvokeTab(NoiseMapGenTabName); +} + +void FNoiseMapGeneratorModule::RegisterMenus() +{ + FToolMenuOwnerScoped OwnerScoped(this); + + if (UToolMenu* Menu = UToolMenus::Get()->ExtendMenu("LevelEditor.MainMenu.Window")) + { + // Insert a custom section at the top. + FToolMenuSection& TopSection = Menu->AddSection( + "NoiseMapGeneratorSection", + FText::GetEmpty(), + FToolMenuInsert(NAME_None, EToolMenuInsertType::First) + ); + + TopSection.AddMenuEntry( + "OpenNoiseMapGenerator", + FText::FromString("Noise Map Generator"), + FText::FromString("Open the Noise Map Generator window"), + FSlateIcon(), + FUIAction(FExecuteAction::CreateRaw(this, &FNoiseMapGeneratorModule::OpenWindow)) + ); + } +} + +IMPLEMENT_MODULE(FNoiseMapGeneratorModule, NoiseMapGenerator) diff --git a/Plugins/NoiseMapGenerator/Source/NoiseMapGenerator/Private/NoiseMapGeneratorSettings.cpp b/Plugins/NoiseMapGenerator/Source/NoiseMapGenerator/Private/NoiseMapGeneratorSettings.cpp new file mode 100644 index 00000000..a2d9d1f4 --- /dev/null +++ b/Plugins/NoiseMapGenerator/Source/NoiseMapGenerator/Private/NoiseMapGeneratorSettings.cpp @@ -0,0 +1,8 @@ +/* Copyright UNmisterIZE(Sebastien Durocher) 2025 All Rights Reserved. + +Project: NoiseMapGenerator Plugin +File: NoiseMapGeneratorSettings.cpp +*/ + +#include "NoiseMapGeneratorSettings.h" + diff --git a/Plugins/NoiseMapGenerator/Source/NoiseMapGenerator/Public/NoiseMapGenerator.h b/Plugins/NoiseMapGenerator/Source/NoiseMapGenerator/Public/NoiseMapGenerator.h new file mode 100644 index 00000000..d9351b79 --- /dev/null +++ b/Plugins/NoiseMapGenerator/Source/NoiseMapGenerator/Public/NoiseMapGenerator.h @@ -0,0 +1,40 @@ +/* Copyright UNmisterIZE(Sebastien Durocher) 2025 All Rights Reserved. + +Project: NoiseMapGenerator Plugin +File: NoiseMapGenerator.h +*/ +#pragma once + +#include "CoreMinimal.h" +#include "NoiseMapGeneratorSettings.h" + +class UTexture2D; + +struct FNoiseBakeSettings +{ + int32 Width = 1024; + int32 Height = 1024; + int32 TileRepeats = 1; + int32 Seed = 1337; + int32 Octaves = 5; + float Frequency = 0.25f; + float Lacunarity = 2.0f; + float Gain = 0.5f; + bool bNormalize = true; + ENoiseMode Mode = ENoiseMode::FBM; + ENoiseAlgorithm Algorithm = ENoiseAlgorithm::Perlin; + ENoiseOutputMode OutputMode = ENoiseOutputMode::Grayscale; + FString OutputPath = TEXT("/Game/Noise"); + FString BaseName = TEXT("T_Noise"); + bool bOverwrite = false; + bool bGenerateMips = false; +}; + +class NOISEMAPGENERATOR_API FNoiseMapGenerator + +{ +public: + static bool GeneratePixels(const FNoiseBakeSettings& S, int32 RepeatsUV, TArray& OutPixels, int32& OutW, int32& OutH); + static void BakeNoiseTexture(const FNoiseBakeSettings& Settings); + static void BakeFromPixels(const FNoiseBakeSettings& S, const TArray& Pixels, int32 W, int32 H); +}; \ No newline at end of file diff --git a/Plugins/NoiseMapGenerator/Source/NoiseMapGenerator/Public/NoiseMapGeneratorModule.h b/Plugins/NoiseMapGenerator/Source/NoiseMapGenerator/Public/NoiseMapGeneratorModule.h new file mode 100644 index 00000000..23925c64 --- /dev/null +++ b/Plugins/NoiseMapGenerator/Source/NoiseMapGenerator/Public/NoiseMapGeneratorModule.h @@ -0,0 +1,22 @@ +/* Copyright UNmisterIZE(Sebastien Durocher) 2025 All Rights Reserved. + +Project: NoiseMapGenerator Plugin +File: NoiseMapGeneratorModule.h +*/ +#pragma once + +#include "CoreMinimal.h" +#include "Modules/ModuleInterface.h" +#include "Modules/ModuleManager.h" +#include "ToolMenus.h" + +class FNoiseMapGeneratorModule : public IModuleInterface +{ +public: + virtual void StartupModule() override; + virtual void ShutdownModule() override; + +private: + void OpenWindow(); + void RegisterMenus(); +}; \ No newline at end of file diff --git a/Plugins/NoiseMapGenerator/Source/NoiseMapGenerator/Public/NoiseMapGeneratorSettings.h b/Plugins/NoiseMapGenerator/Source/NoiseMapGenerator/Public/NoiseMapGeneratorSettings.h new file mode 100644 index 00000000..1ce5b23f --- /dev/null +++ b/Plugins/NoiseMapGenerator/Source/NoiseMapGenerator/Public/NoiseMapGeneratorSettings.h @@ -0,0 +1,103 @@ +/* Copyright UNmisterIZE(Sebastien Durocher) 2025 All Rights Reserved. + +Project: NoiseMapGenerator Plugin +File: NoiseMapGeneratorSettings.h +*/ +#pragma once + +#include "CoreMinimal.h" +#include "Engine/DeveloperSettings.h" +#include "NoiseMapGeneratorSettings.generated.h" + +UENUM() +enum class ENoiseMode : uint8 +{ + Perlin, + FBM +}; + +UENUM() +enum class ENoiseOutputMode : uint8 +{ + Grayscale, + SplitFirst3Octaves +}; + +UENUM() +enum class ENoiseAlgorithm : uint8 +{ + Perlin UMETA(DisplayName="Perlin"), + Worley UMETA(DisplayName="Worley (Cellular)"), + RidgedFBM UMETA(DisplayName="Ridged FBM") +}; + + +UCLASS(config=EditorPerProjectUserSettings, defaultconfig) +class UNoiseMapGeneratorSettings : public UDeveloperSettings +{ + GENERATED_BODY() + +public: + virtual FName GetCategoryName() const override { return TEXT("Plugins"); } + virtual FText GetSectionText() const override { return FText::FromString(TEXT("Noise Map Generator")); } + + + // Resolution + UPROPERTY(EditAnywhere, config, Category = "Noise", meta=(ClampMin="8", ClampMax="16384")) + int32 Width = 1024; + UPROPERTY(EditAnywhere, config, Category = "Noise", meta=(ClampMin="8", ClampMax="16384")) + int32 Height = 1024; + + // Tiling + UPROPERTY(EditAnywhere, config, Category = "Noise", meta=(ClampMin="1", ClampMax="64")) + int32 TileRepeats = 1; + + // Noise parameters + UPROPERTY(EditAnywhere, config, Category = "Noise") + int32 Seed = 1337; + + // Used by FBM (ignored for Perlin-only grayscale) + UPROPERTY(EditAnywhere, config, Category = "Noise", meta=(ClampMin="1", ClampMax="12")) + int32 Octaves = 5; + + // features size + UPROPERTY(EditAnywhere, config, Category = "Noise", meta=(ClampMin="0.001", ClampMax="2.0")) + float Frequency = 0.5f; + + // Frequency multiplier per octave + UPROPERTY(EditAnywhere, config, Category = "Noise", meta=(ClampMin="1.0", ClampMax="8.0")) + float Lacunarity = 2.0f; + + // Amplitude multiplier per octave + UPROPERTY(EditAnywhere, config, Category = "Noise", meta=(ClampMin="0.0", ClampMax="1.0")) + float Gain = 0.5f; + + // Normalize result to [0..1] (if off, uses 0.5*(v+1)) + UPROPERTY(EditAnywhere, config, Category = "Noise") + bool bNormalize = true; + + // Perlin or FBM for grayscale output + UPROPERTY(EditAnywhere, config, Category = "Noise") + ENoiseMode Mode = ENoiseMode::FBM; + + // Which base algorithm to use + UPROPERTY(EditAnywhere, config, Category = "Noise") + ENoiseAlgorithm Algorithm = ENoiseAlgorithm::Perlin; + + // Grayscale or pack first 3 octaves into RGB + UPROPERTY(EditAnywhere, config, Category = "Noise") + ENoiseOutputMode OutputMode = ENoiseOutputMode::Grayscale; + + // Save opt + UPROPERTY(EditAnywhere, config, Category = "Save") + FString OutputPath = TEXT("/Game/Noise"); + + UPROPERTY(EditAnywhere, config, Category = "Save") + FString BaseName = TEXT("T_Noise"); + + UPROPERTY(EditAnywhere, config, Category = "Save") + bool bOverwrite = false; + + //UPROPERTY(EditAnywhere, config, Category = "Save") + // bool bGenerateMips = false; +}; \ No newline at end of file diff --git a/Plugins/PSOFunctionLibrary/PSOFunctions.uplugin b/Plugins/PSOFunctionLibrary/PSOFunctions.uplugin new file mode 100644 index 00000000..18b20f27 --- /dev/null +++ b/Plugins/PSOFunctionLibrary/PSOFunctions.uplugin @@ -0,0 +1,25 @@ +{ + "FileVersion": 3, + "Version": 1, + "VersionName": "1.0", + "FriendlyName": "PSOFunctions", + "Description": "Esposes ShaderPipelineCache functions to Blueprint project", + "Category": "Other", + "CreatedBy": "Danielel", + "CreatedByURL": "", + "DocsURL": "https://www.dropbox.com/scl/fi/bjhz5wmklikkojlukz92g/PSOFunctions-Plugin-Documentation.pdf?rlkey=hfowk5im2gnnc58xwrsbfxvjr&st=82lih7p5&dl=0", + "MarketplaceURL": "", + "SupportURL": "", + "CanContainContent": true, + "Installed": true, + "Modules": [ + { + "Name": "PSOFunctions", + "Type": "Runtime", + "LoadingPhase": "Default", + "PlatformAllowList": [ + "Win64" + ] + } + ] +} \ No newline at end of file diff --git a/Plugins/PSOFunctionLibrary/Resources/Icon128.png b/Plugins/PSOFunctionLibrary/Resources/Icon128.png new file mode 100644 index 00000000..26245f6a --- /dev/null +++ b/Plugins/PSOFunctionLibrary/Resources/Icon128.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f7239efaeefbd82de33ebe18518e50de075ea4188a468a9e4991396433d2275f +size 12699 diff --git a/Plugins/PSOFunctionLibrary/Source/PSOFunctions/PSOFunctions.Build.cs b/Plugins/PSOFunctionLibrary/Source/PSOFunctions/PSOFunctions.Build.cs new file mode 100644 index 00000000..c08fc5b1 --- /dev/null +++ b/Plugins/PSOFunctionLibrary/Source/PSOFunctions/PSOFunctions.Build.cs @@ -0,0 +1,55 @@ +// Copyright (c) 2025 Danielel. All rights reserved. + +using UnrealBuildTool; + +public class PSOFunctions : ModuleRules +{ + public PSOFunctions(ReadOnlyTargetRules Target) : base(Target) + { + PCHUsage = ModuleRules.PCHUsageMode.UseExplicitOrSharedPCHs; + + PublicIncludePaths.AddRange( + new string[] { + // ... add public include paths required here ... + } + ); + + + PrivateIncludePaths.AddRange( + new string[] { + // ... add other private include paths required here ... + } + ); + + + PublicDependencyModuleNames.AddRange( + new string[] + { + "Core", + // ... add other public dependencies that you statically link with here ... + } + ); + + + PrivateDependencyModuleNames.AddRange( + new string[] + { + "Core", + "CoreUObject", + "Engine", + "Slate", + "SlateCore", + "RenderCore" + // ... add private dependencies that you statically link with here ... + } + ); + + + DynamicallyLoadedModuleNames.AddRange( + new string[] + { + // ... add any modules that your module loads dynamically here ... + } + ); + } +} diff --git a/Plugins/PSOFunctionLibrary/Source/PSOFunctions/Private/PSOFunctions.cpp b/Plugins/PSOFunctionLibrary/Source/PSOFunctions/Private/PSOFunctions.cpp new file mode 100644 index 00000000..c065f743 --- /dev/null +++ b/Plugins/PSOFunctionLibrary/Source/PSOFunctions/Private/PSOFunctions.cpp @@ -0,0 +1,20 @@ +// Copyright (c) 2025 Danielel. All rights reserved. + +#include "PSOFunctions.h" + +#define LOCTEXT_NAMESPACE "FPSOFunctionsModule" + +void FPSOFunctionsModule::StartupModule() +{ + // This code will execute after your module is loaded into memory; the exact timing is specified in the .uplugin file per-module +} + +void FPSOFunctionsModule::ShutdownModule() +{ + // This function may be called during shutdown to clean up your module. For modules that support dynamic reloading, + // we call this function before unloading the module. +} + +#undef LOCTEXT_NAMESPACE + +IMPLEMENT_MODULE(FPSOFunctionsModule, PSOFunctions) \ No newline at end of file diff --git a/Plugins/PSOFunctionLibrary/Source/PSOFunctions/Private/ShaderPipeline.cpp b/Plugins/PSOFunctionLibrary/Source/PSOFunctions/Private/ShaderPipeline.cpp new file mode 100644 index 00000000..c98aeb95 --- /dev/null +++ b/Plugins/PSOFunctionLibrary/Source/PSOFunctions/Private/ShaderPipeline.cpp @@ -0,0 +1,46 @@ +// Copyright (c) 2025 Danielel. All rights reserved. + +#include "ShaderPipeline.h" +#include "ShaderPipelineCache.h" +#include "RenderResource.h" + + +void UShaderPipeline::SetBatchModeFast() +{ + FShaderPipelineCache::SetBatchMode(FShaderPipelineCache::BatchMode::Fast); +} + +void UShaderPipeline::SetBatchModeBackground() +{ + FShaderPipelineCache::SetBatchMode(FShaderPipelineCache::BatchMode::Background); +} + +void UShaderPipeline::SetBatchModePrecompile() +{ + FShaderPipelineCache::SetBatchMode(FShaderPipelineCache::BatchMode::Precompile); +} + +void UShaderPipeline::PauseBatching() +{ + FShaderPipelineCache::PauseBatching(); +} + +void UShaderPipeline::ResumeBatching() +{ + FShaderPipelineCache::ResumeBatching(); +} + +bool UShaderPipeline::IsPrecompiling() +{ + return FShaderPipelineCache::IsPrecompiling(); +} + +int UShaderPipeline::NumPrecompilesRemaining() +{ + return FShaderPipelineCache::NumPrecompilesRemaining(); +} + +bool UShaderPipeline::IsBatchingPaused() +{ + return FShaderPipelineCache::IsBatchingPaused(); +} diff --git a/Plugins/PSOFunctionLibrary/Source/PSOFunctions/Public/PSOFunctions.h b/Plugins/PSOFunctionLibrary/Source/PSOFunctions/Public/PSOFunctions.h new file mode 100644 index 00000000..5ddc563e --- /dev/null +++ b/Plugins/PSOFunctionLibrary/Source/PSOFunctions/Public/PSOFunctions.h @@ -0,0 +1,15 @@ +// Copyright (c) 2025 Danielel. All rights reserved. + +#pragma once + +#include "CoreMinimal.h" +#include "Modules/ModuleManager.h" + +class FPSOFunctionsModule : public IModuleInterface +{ +public: + + /** IModuleInterface implementation */ + virtual void StartupModule() override; + virtual void ShutdownModule() override; +}; diff --git a/Plugins/PSOFunctionLibrary/Source/PSOFunctions/Public/ShaderPipeline.h b/Plugins/PSOFunctionLibrary/Source/PSOFunctions/Public/ShaderPipeline.h new file mode 100644 index 00000000..2fbb8b5d --- /dev/null +++ b/Plugins/PSOFunctionLibrary/Source/PSOFunctions/Public/ShaderPipeline.h @@ -0,0 +1,51 @@ +// Copyright (c) 2025 Danielel. All rights reserved. + +#pragma once + +#include "CoreMinimal.h" +#include "Kismet/BlueprintFunctionLibrary.h" +#include "ShaderPipelineCache.h" + +#include "ShaderPipeline.generated.h" + +/** + * + */ +UCLASS() +class PSOFUNCTIONS_API UShaderPipeline : public UBlueprintFunctionLibrary +{ + GENERATED_BODY() + +public: + + + + UFUNCTION(BlueprintCallable, Category = "PSO precompile", meta = (ToolTip = "Sets the precompilation batching mode to Fast")) + static void SetBatchModeFast(); + + UFUNCTION(BlueprintCallable, Category = "PSO precompile", meta = (ToolTip = "Sets the precompilation batching mode to Background")) + static void SetBatchModeBackground(); + + UFUNCTION(BlueprintCallable, Category = "PSO precompile", meta = (ToolTip = "Sets the precompilation batching mode to Precompile")) + static void SetBatchModePrecompile(); + + /** Pauses precompilation batching. */ + UFUNCTION(BlueprintCallable, Category = "PSO precompile", meta = (ToolTip = "Pauses precompilation batching")) + static void PauseBatching(); + + /** Resumes precompilation batching. */ + UFUNCTION(BlueprintCallable, Category = "PSO precompile", meta = (ToolTip = "Resumes precompilation batching")) + static void ResumeBatching(); + + /** true if pipeline cache(s) are precompiling. */ + UFUNCTION(BlueprintCallable, Category = "PSO precompile", BlueprintPure, meta = (ToolTip = "True if pipeline cache(s) are precompiling")) + static bool IsPrecompiling(); + + /** Returns the number of pipelines waiting for precompilation of the current PSOFC task. + if there are multiple PSOFCs pending this will return a minimum value of 1. It may increase as subsequent caches are processed.*/ + UFUNCTION(BlueprintCallable, Category = "PSO precompile", BlueprintPure, meta = (ToolTip = "Returns the number of pipelines waiting for precompilation of the current PSOFC task. If there are multiple PSOFCs pending this will return a minimum value of 1. It may increase as subsequent caches are processed.")) + static int NumPrecompilesRemaining(); + + UFUNCTION(BlueprintCallable, Category = "PSO precompile", BlueprintPure, meta = (ToolTip = "Returns if the precompilation batching is paused")) + static bool IsBatchingPaused(); +}; diff --git a/Plugins/SUDS/Content/Editor/Slate/Icons/SUDSScript_16x.png b/Plugins/SUDS/Content/Editor/Slate/Icons/SUDSScript_16x.png new file mode 100644 index 00000000..088330f9 --- /dev/null +++ b/Plugins/SUDS/Content/Editor/Slate/Icons/SUDSScript_16x.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:22dbadfb392e532abb4d49760463c8c3b91de07c13f43e374ccd0119d1e886e2 +size 814 diff --git a/Plugins/SUDS/Content/Editor/Slate/Icons/SUDSScript_64x.png b/Plugins/SUDS/Content/Editor/Slate/Icons/SUDSScript_64x.png new file mode 100644 index 00000000..e95a9474 --- /dev/null +++ b/Plugins/SUDS/Content/Editor/Slate/Icons/SUDSScript_64x.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c2e765a7c9d335d2203ebca354e1eb8419c7bc105aa265be8f5d4d24f7c8a3ca +size 5615 diff --git a/Plugins/SUDS/Extras/PygmentsLexer/.vscode/settings.json b/Plugins/SUDS/Extras/PygmentsLexer/.vscode/settings.json new file mode 100644 index 00000000..50b05174 --- /dev/null +++ b/Plugins/SUDS/Extras/PygmentsLexer/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "cSpell.words": [ + "speakerline", + "squarebrackets" + ] +} \ No newline at end of file diff --git a/Plugins/SUDS/Extras/PygmentsLexer/setup.py b/Plugins/SUDS/Extras/PygmentsLexer/setup.py new file mode 100644 index 00000000..c8b594c3 --- /dev/null +++ b/Plugins/SUDS/Extras/PygmentsLexer/setup.py @@ -0,0 +1,12 @@ +from setuptools import setup, find_packages + +setup ( + name='sudslexer', + packages=find_packages(), + version="0.4.0", + entry_points = + """ + [pygments.lexers] + sudslexer = sudslexer.lexer:SudsLexer + """, +) \ No newline at end of file diff --git a/Plugins/SUDS/Extras/PygmentsLexer/sudslexer/__init__.py b/Plugins/SUDS/Extras/PygmentsLexer/sudslexer/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/Plugins/SUDS/Extras/PygmentsLexer/sudslexer/lexer.py b/Plugins/SUDS/Extras/PygmentsLexer/sudslexer/lexer.py new file mode 100644 index 00000000..66eec43e --- /dev/null +++ b/Plugins/SUDS/Extras/PygmentsLexer/sudslexer/lexer.py @@ -0,0 +1,71 @@ +from pygments.lexer import RegexLexer, bygroups +from pygments.token import * + +class SudsLexer(RegexLexer): + name = 'SUDS' + aliases = ['suds'] + filenames = ['*.sud'] + + tokens = { + 'root': [ + (r'===\s*\n', Generic.Heading), + # Choices + (r'\s*\*\-?\s+[^\@\n]+[\@\n]', Generic.Subheading), + (r'\s*#.*\n', Comment), + # Speaker lines + (r'\s*\S+\:', Name.Class, 'speakerline'), + # Open brackets for special functions + (r'\[', Operator, 'squarebrackets'), + + # Variables + (r'(\{)([\w\.]+)(\})', bygroups(Operator, Name.Variable, Operator)), + (r'(\|)(plural|gender)(\()(.*?)(\))', bygroups(Operator, Keyword, Operator, Keyword, Operator)), + (r'\b([tT]rue|[fF]alse|[mM]asculine|[fF]eminine|[nN]euter)\b', Name.Constant), + # Line IDs + (r'\@[0-9a-fA-F]+\@', Comment.Special), + # Embedded markup + (r'\<\w+\>', Name.Decorator), + (r'\<\/\>', Name.Decorator), + # Goto labels + (r'\s*:\S+\n', Name.Label), + # Comments + (r'\s*\#[\=\+\%]?.*\n', Comment.Special), + # Fallback for all other text + # Needs an optional \n on the end to finish lines correctly + (r'\s+[\n]?', Text), + (r'\S+?[\n]?', Text), # non-greedy so we don't consume all non-whitespace + + + ], + # While in a speaker line, we ignore everything else except variables & markup + 'speakerline' : [ + # Variables + (r'(\{)([\w\.]+)(\})', bygroups(Operator, Name.Variable, Operator)), + (r'(\|)(plural|gender)(\()(.*?)(\))', bygroups(Operator, Keyword, Operator, Keyword, Operator)), + (r'[\@\n]', Text, '#pop'), + # Embedded markup + (r'\<\w+\>', Name.Decorator), + (r'\<\/\>', Name.Decorator), + (r'[^\@\n\<\{]+', Text), + + ], + # While in a [] block, highlight operators etc (don't do it elsewhere) + 'squarebrackets' : [ + # Close bracket finishes + (r'\]\s*[\n]?', Operator, '#pop'), + # Variables + (r'(\{)([\w\.]+)(\})', bygroups(Operator, Name.Variable, Operator)), + (r'\+\/\-\*\!\%', Operator), + (r'\"[^\"]*\"', String.Double), + (r'\`[^\`]*\`', String.Escape), + (r'\d+(\.\d+)?', Number), + (r'\b([tT]rue|[fF]alse|[mM]asculine|[fF]eminine|[nN]euter)\b', Name.Constant), + # Set, event commands so we can highlight variable/event differently + (r'\s*(set|event)(\s+)([^\]\s]+)', bygroups(Keyword, Text, Name.Variable)), + (r'\s*(if|else|elseif|endif|event|return|goto|gosub|go to|go sub|random|endrandom)\b', Keyword), + (r'\b(and|or|&&|\|\||not)\b', Operator), + (r'[,]', Punctuation), + (r'\s+', Text), # whitespace OK + + ] + } \ No newline at end of file diff --git a/Plugins/SUDS/License.txt b/Plugins/SUDS/License.txt new file mode 100644 index 00000000..2e0d6a72 --- /dev/null +++ b/Plugins/SUDS/License.txt @@ -0,0 +1,18 @@ +The MIT License (MIT) Copyright © 2022 Steve Streeting + +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. \ No newline at end of file diff --git a/Plugins/SUDS/Resources/Icon128.png b/Plugins/SUDS/Resources/Icon128.png new file mode 100644 index 00000000..8fda0ada --- /dev/null +++ b/Plugins/SUDS/Resources/Icon128.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4d56a57fca5beb2d44bd0367dda7b67607685698704df37b4d7a9843c254c05d +size 13715 diff --git a/Plugins/SUDS/Resources/logo.afdesign b/Plugins/SUDS/Resources/logo.afdesign new file mode 100644 index 00000000..f50f54b4 Binary files /dev/null and b/Plugins/SUDS/Resources/logo.afdesign differ diff --git a/Plugins/SUDS/SUDS.uplugin b/Plugins/SUDS/SUDS.uplugin new file mode 100644 index 00000000..b8256bab --- /dev/null +++ b/Plugins/SUDS/SUDS.uplugin @@ -0,0 +1,33 @@ +{ + "FileVersion": 3, + "Version": 1, + "VersionName": "1.0", + "FriendlyName": "SUDS", + "Description": "Steve's Unreal Dialogue System", + "Category": "Other", + "CreatedBy": "Steve Streeting", + "CreatedByURL": "", + "DocsURL": "", + "MarketplaceURL": "", + "CanContainContent": true, + "IsBetaVersion": false, + "IsExperimentalVersion": false, + "Installed": false, + "Modules": [ + { + "Name": "SUDS", + "Type": "Runtime", + "LoadingPhase": "Default" + }, + { + "Name": "SUDSEditor", + "Type": "Editor", + "LoadingPhase": "Default" + }, + { + "Name": "SUDSTest", + "Type": "Editor", + "LoadingPhase": "Default" + } + ] +} \ No newline at end of file diff --git a/Plugins/SUDS/Source/SUDS/Private/SUDS.cpp b/Plugins/SUDS/Source/SUDS/Private/SUDS.cpp new file mode 100644 index 00000000..61f5ef77 --- /dev/null +++ b/Plugins/SUDS/Source/SUDS/Private/SUDS.cpp @@ -0,0 +1,21 @@ +// Copyright Steve Streeting 2022 +// Released under the MIT license https://opensource.org/license/MIT/ +#include "SUDS.h" +#include "Modules/ModuleManager.h" + +#define LOCTEXT_NAMESPACE "FSUDSModule" + +void FSUDSModule::StartupModule() +{ + // This code will execute after your module is loaded into memory; the exact timing is specified in the .uplugin file per-module +} + +void FSUDSModule::ShutdownModule() +{ + // This function may be called during shutdown to clean up your module. For modules that support dynamic reloading, + // we call this function before unloading the module. +} + +#undef LOCTEXT_NAMESPACE + +IMPLEMENT_MODULE(FSUDSModule, SUDS) diff --git a/Plugins/SUDS/Source/SUDS/Private/SUDSCommon.cpp b/Plugins/SUDS/Source/SUDS/Private/SUDSCommon.cpp new file mode 100644 index 00000000..55c2c062 --- /dev/null +++ b/Plugins/SUDS/Source/SUDS/Private/SUDSCommon.cpp @@ -0,0 +1,4 @@ +#include "SUDSCommon.h" + +const FName FSUDSConstants::RandomItemSelectIndexVarName(SUDS_RANDOMITEM_VAR); + diff --git a/Plugins/SUDS/Source/SUDS/Private/SUDSDialogue.cpp b/Plugins/SUDS/Source/SUDS/Private/SUDSDialogue.cpp new file mode 100644 index 00000000..0784eb99 --- /dev/null +++ b/Plugins/SUDS/Source/SUDS/Private/SUDSDialogue.cpp @@ -0,0 +1,1379 @@ +// Copyright Steve Streeting 2022 +// Released under the MIT license https://opensource.org/license/MIT/ +#include "SUDSDialogue.h" + +#include "SUDSInternal.h" +#include "SUDSLibrary.h" +#include "SUDSParticipant.h" +#include "SUDSScript.h" +#include "SUDSScriptNode.h" +#include "SUDSScriptNodeEvent.h" +#include "SUDSScriptNodeGosub.h" +#include "SUDSScriptNodeSet.h" +#include "SUDSScriptNodeText.h" +#include "SUDSSubsystem.h" +#include "Kismet/GameplayStatics.h" +#include "Sound/DialogueSoundWaveProxy.h" +#include "Sound/DialogueWave.h" + +DEFINE_LOG_CATEGORY(LogSUDSDialogue); + +const FText USUDSDialogue::DummyText = FText::FromString("INVALID"); +const FString USUDSDialogue::DummyString = "INVALID"; + + + +FArchive& operator<<(FArchive& Ar, FSUDSDialogueState& Value) +{ + Ar << Value.TextNodeID; + Ar << Value.Variables; + Ar << Value.ChoicesTaken; + Ar << Value.ReturnStack; + + return Ar; +} + +void operator<<(FStructuredArchive::FSlot Slot, FSUDSDialogueState& Value) +{ + FStructuredArchive::FRecord Record = Slot.EnterRecord(); + Record + << SA_VALUE(TEXT("TextNodeID"), Value.TextNodeID) + << SA_VALUE(TEXT("Variables"), Value.Variables) + << SA_VALUE(TEXT("ChoicesTaken"), Value.ChoicesTaken) + << SA_VALUE(TEXT("ReturnStack"), Value.ReturnStack); + +} + +USUDSDialogue::USUDSDialogue(): BaseScript(nullptr), + CurrentSpeakerNode(nullptr), + CurrentRootChoiceNode(nullptr), + bParamNamesExtracted(false), + CurrentSourceLineNo(0) +{ +} + +void USUDSDialogue::Initialise(const USUDSScript* Script) +{ + BaseScript = Script; + CurrentSpeakerNode = nullptr; + + InitVariables(); + + CurrentSpeakerNode = nullptr; + +} + +void USUDSDialogue::InitVariables() +{ + VariableState.Empty(); + // Run header nodes immediately (only set nodes) + RunUntilNextSpeakerNodeOrEnd(BaseScript->GetHeaderNode(), false); +} + +void USUDSDialogue::Start(FName Label) +{ + // Only start if not already on a speaker node + // This makes the restore sequence easier, you don't have to test IsEnded + if (!IsValid(CurrentSpeakerNode)) + { + // Note that we don't reset state by default here. This is to allow long-term memory on dialogue, such as + // knowing whether you've met a character before etc. + // We also don't re-run headers here since they will have been run on Initialise() + // This is to allow callers to set variables before Start() that override headers + Restart(false, Label, false); + } +} + +void USUDSDialogue::SetParticipants(const TArray& InParticipants) +{ + // Protect against null participants + // Since our stable sort dereferences + Participants.Empty(); + for (auto P : InParticipants) + { + if (IsValid(P)) + { + Participants.AddUnique(P); + } + } + SortParticipants(); +} + +void USUDSDialogue::AddParticipant(UObject* Participant) +{ + if (IsValid(Participant)) + { + Participants.AddUnique(Participant); + SortParticipants(); + } +} + +void USUDSDialogue::SortParticipants() +{ + if (!Participants.IsEmpty()) + { + // We order by ascending priority so that higher priority values are later in the list + // Which means they're called last and get to override values set by earlier ones + // We'll do a stable sort so that otherwise order is maintained + Participants.StableSort([](const UObject& A, const UObject& B) + { + if (A.Implements() && B.Implements()) + { + return ISUDSParticipant::Execute_GetDialogueParticipantPriority(&A) < + ISUDSParticipant::Execute_GetDialogueParticipantPriority(&B); + } + // Be deterministic + return &A < &B; + }); + } +} + +void USUDSDialogue::RunUntilNextSpeakerNodeOrEnd(USUDSScriptNode* NextNode, bool bRaiseAtEnd) +{ + // We run through nodes which don't require a speaker line prompt + // E.g. set nodes, select nodes which are all automatically resolved + // Starting with this node + while (NextNode && !IsChoiceOrTextNode(NextNode->GetNodeType())) + { + NextNode = RunNode(NextNode); + } + + if (NextNode) + { + if (NextNode->GetNodeType() == ESUDSScriptNodeType::Text) + { + SetCurrentSpeakerNode(Cast(NextNode), false); + } + else + { + // This can happen if for example user creates a choice node as the first thing + UE_LOG(LogSUDSDialogue, + Error, + TEXT("Error in %s line %d: Tried to run to next speaker node but encountered unexpected node of type %s"), + *BaseScript->GetName(), + NextNode->GetSourceLineNo(), + *(StaticEnum()->GetValueAsString(NextNode->GetNodeType())) + ); + } + } + else + { + End(!bRaiseAtEnd); + } + +} + +USUDSScriptNode* USUDSDialogue::RunNode(USUDSScriptNode* Node) +{ + CurrentSourceLineNo = Node->GetSourceLineNo(); + switch (Node->GetNodeType()) + { + case ESUDSScriptNodeType::Select: + return RunSelectNode(Node); + case ESUDSScriptNodeType::SetVariable: + return RunSetVariableNode(Node); + case ESUDSScriptNodeType::Event: + return RunEventNode(Node); + case ESUDSScriptNodeType::Gosub: + return RunGosubNode(Node); + case ESUDSScriptNodeType::Return: + return RunReturnNode(Node); + default: ; + } + + UE_LOG(LogSUDSDialogue, + Error, + TEXT("Error in %s line %d: Attempted to run non-runnable node type %s"), + *BaseScript->GetName(), + Node->GetSourceLineNo(), + *(StaticEnum()->GetValueAsString(Node->GetNodeType())) + ) + return nullptr; +} + +USUDSScriptNode* USUDSDialogue::RunSelectNode(USUDSScriptNode* Node) +{ + // Define internal random selection variable (used in random selects) + if (Node->IsRandomSelect()) + { + // Random picker + // Could try to NOT pick the same ones we already picked, but this would require some additional state, similar + // to "ChoicesTaken" state but for random text nodes already chosen. For now, keep it simple + + const int OptCount = Node->GetEdgeCount(); + // Use SRand() so can be seeded if required + const int RandChoice = FMath::Min(OptCount-1, FMath::TruncToInt(FMath::SRand() * (float)OptCount)); + + SetVariableInt(FSUDSConstants::RandomItemSelectIndexVarName, RandChoice); + } + + for (auto& Edge : Node->GetEdges()) + { + if (Edge.GetCondition().IsValid()) + { + // use the first satisfied edge + RaiseExpressionVariablesRequested(Edge.GetCondition(), Edge.GetSourceLineNo()); + const bool bSuccess = Edge.GetCondition().EvaluateBoolean(VariableState, GetGlobalVariables(), BaseScript->GetName()); +#if WITH_EDITOR + { + FString ExprStr = Edge.GetCondition().GetSourceString(); + if (ExprStr.IsEmpty()) + { + // Lack of condition is an else / final random option + ExprStr = "else"; + } + InternalOnSelectEval.ExecuteIfBound(this, ExprStr, bSuccess, Edge.GetSourceLineNo()); + } +#endif + + if (bSuccess) + { + return Edge.GetTargetNode().Get(); + } + } + } + // NOTE: if no valid path, go to end + // We've already created fall-through else nodes if possible + return nullptr; +} + +USUDSScriptNode* USUDSDialogue::RunEventNode(USUDSScriptNode* Node) +{ + if (USUDSScriptNodeEvent* EvtNode = Cast(Node)) + { + // Build a resolved args list, because we need to evaluate expressions + TArray ArgsResolved; + + for (auto& Expr : EvtNode->GetArgs()) + { + RaiseExpressionVariablesRequested(Expr, EvtNode->GetSourceLineNo()); + ArgsResolved.Add(Expr.Evaluate(VariableState, GetGlobalVariables())); + } + + for (const auto& P : Participants) + { + if (P->GetClass()->ImplementsInterface(USUDSParticipant::StaticClass())) + { + ISUDSParticipant::Execute_OnDialogueEvent(P, this, EvtNode->GetEventName(), ArgsResolved); + } + } + OnEvent.Broadcast(this, EvtNode->GetEventName(), ArgsResolved); +#if WITH_EDITOR + InternalOnEvent.ExecuteIfBound(this, EvtNode->GetEventName(), ArgsResolved, EvtNode->GetSourceLineNo()); +#endif + } + return GetNextNode(Node); +} + +USUDSScriptNode* USUDSDialogue::RunGosubNode(USUDSScriptNode* Node) +{ + if (USUDSScriptNodeGosub* GosubNode = Cast(Node)) + { + if (auto TargetNode = BaseScript->GetNodeByLabel(GosubNode->GetLabelName())) + { + // Push this gosub node to the return stack, then jump + GosubReturnStack.Push(GosubNode); + return TargetNode; + } + else + { + UE_LOG(LogSUDSDialogue, + Error, + TEXT("Error in %s: Cannot gosub to label '%s', was not found"), + *BaseScript->GetName(), + *GosubNode->GetLabelName().ToString()); + + } + } + return GetNextNode(Node); +} + +USUDSScriptNode* USUDSDialogue::RunReturnNode(USUDSScriptNode* Node) +{ + if (GosubReturnStack.Num() > 0) + { + // We return to the next node after the gosub, which temporarily redirected + const auto GoSubNode = GosubReturnStack.Pop(); + return GetNextNode(GoSubNode); + } + else + { + UE_LOG(LogSUDSDialogue, + Error, + TEXT("Attempted to return at %s:%d but there was no previous gosub to return to"), + *BaseScript->GetName(), + Node->GetSourceLineNo()); + return nullptr; + + } +} + +USUDSScriptNode* USUDSDialogue::RunSetVariableNode(USUDSScriptNode* Node) +{ + if (USUDSScriptNodeSet* SetNode = Cast(Node)) + { + if (SetNode->GetExpression().IsValid()) + { + RaiseExpressionVariablesRequested(SetNode->GetExpression(), SetNode->GetSourceLineNo()); + FSUDSValue Value = SetNode->GetExpression().Evaluate(VariableState, GetGlobalVariables()); + FName Identifier; + if (USUDSLibrary::IsDialogueVariableGlobal(SetNode->GetIdentifier(), Identifier)) + { + InternalSetGlobalVariable(this->GetWorld(), Identifier, Value, true, BaseScript->GetName(), SetNode->GetSourceLineNo()); + } + else + { + SetVariableImpl(SetNode->GetIdentifier(), Value, true, SetNode->GetSourceLineNo()); + } +#if WITH_EDITOR + // We do this here so that we have access to the expression + InternalOnSetVar.ExecuteIfBound(this, + SetNode->GetIdentifier(), + Value, + SetNode->GetExpression().IsLiteral() + ? "" + : SetNode->GetExpression().GetSourceString(), + SetNode->GetSourceLineNo()); +#endif + } + } + + // Always one edge + return GetNextNode(Node); + +} + +void USUDSDialogue::RaiseVariableChange(const FName& VarName, const FSUDSValue& Value, bool bFromScript, int LineNo) +{ + for (const auto& P : Participants) + { + if (P->GetClass()->ImplementsInterface(USUDSParticipant::StaticClass())) + { + ISUDSParticipant::Execute_OnDialogueVariableChanged(P, this, VarName, Value, bFromScript); + } + } + OnVariableChanged.Broadcast(this, VarName, Value, bFromScript); +#if WITH_EDITOR + if (!bFromScript) + { + // Script setting is raised in SetNode so we have access to expressions + InternalOnSetVarByCode.ExecuteIfBound(this, VarName, Value); + } +#endif + +} + +void USUDSDialogue::RaiseVariableRequested(const FName& VarName, int LineNo) +{ + // Because variables set by participants should "win", raise event first + OnVariableRequested.Broadcast(this, VarName); + for (const auto& P : Participants) + { + if (P->GetClass()->ImplementsInterface(USUDSParticipant::StaticClass())) + { + ISUDSParticipant::Execute_OnDialogueVariableRequested(P, this, VarName); + } + } +} + +void USUDSDialogue::RaiseExpressionVariablesRequested(const FSUDSExpression& Expression, int LineNo) +{ + for (auto& Var : Expression.GetVariableNames()) + { + RaiseVariableRequested(Var, LineNo); + } +} + +const TMap& USUDSDialogue::GetGlobalVariables() const +{ + return InternalGetGlobalVariables(this->GetWorld()); +} + +void USUDSDialogue::SetCurrentSpeakerNode(USUDSScriptNodeText* Node, bool bQuietly) +{ + CurrentSpeakerNode = Node; + + CurrentSpeakerDisplayName = FText::GetEmpty(); + bParamNamesExtracted = false; + if (Node) + { + CurrentSourceLineNo = Node->GetSourceLineNo(); + } + else + { + CurrentSourceLineNo = 0; + } + UpdateChoices(); + + if (!bQuietly) + { + if (CurrentSpeakerNode) + RaiseNewSpeakerLine(); + else + RaiseFinished(); + } + +} + +FText USUDSDialogue::ResolveParameterisedText(const TArray Params, const FTextFormat& TextFormat, int LineNo) +{ + for (const auto& P : Params) + { + RaiseVariableRequested(P, LineNo); + } + // Need to make a temp arg list for compatibility + // Also lets us just set the ones we need to + FFormatNamedArguments Args; + GetTextFormatArgs(Params, Args); + return FText::Format(TextFormat, Args); + +} + +void USUDSDialogue::GetTextFormatArgs(const TArray& ArgNames, FFormatNamedArguments& OutArgs) const +{ + for (auto& Name : ArgNames) + { + FName GlobalName; + if (USUDSLibrary::IsDialogueVariableGlobal(Name, GlobalName)) + { + auto& Globals = InternalGetGlobalVariables(this->GetWorld()); + if (const FSUDSValue* Value = Globals.Find(GlobalName)) + { + // Add to format args using name with prefix + OutArgs.Add(Name.ToString(), Value->ToFormatArg()); + } + } + else if (const FSUDSValue* Value = VariableState.Find(Name)) + { + // Use the operator conversion + OutArgs.Add(Name.ToString(), Value->ToFormatArg()); + } + } +} + +FText USUDSDialogue::GetText() +{ + if (CurrentSpeakerNode) + { + if (CurrentSpeakerNode->HasParameters()) + { + return ResolveParameterisedText(CurrentSpeakerNode->GetParameterNames(), + CurrentSpeakerNode->GetTextFormat(), + CurrentSpeakerNode->GetSourceLineNo()); + } + else + { + return CurrentSpeakerNode->GetText(); + } + } + return DummyText; +} + +UDialogueWave* USUDSDialogue::GetWave() const +{ + if (CurrentSpeakerNode) + { + return CurrentSpeakerNode->GetWave(); + } + + return nullptr; +} + +bool USUDSDialogue::IsCurrentLineVoiced() const +{ + if (CurrentSpeakerNode) + { + return IsValid(CurrentSpeakerNode->GetWave()); + } + + return false; +} + +const FString& USUDSDialogue::GetSpeakerID() const +{ + if (CurrentSpeakerNode) + return CurrentSpeakerNode->GetSpeakerID(); + + return DummyString; +} + +FText USUDSDialogue::GetSpeakerDisplayName() const +{ + if (CurrentSpeakerDisplayName.IsEmpty()) + { + // Derive speaker display name + // Is just a special variable "SpeakerName.SpeakerID" + // or just the SpeakerID if none specified + static const FString SpeakerIDPrefix = "SpeakerName."; + FName Key(SpeakerIDPrefix + GetSpeakerID()); + if (auto Arg = VariableState.Find(Key)) + { + if (Arg->GetType() == ESUDSValueType::Text) + { + CurrentSpeakerDisplayName = Arg->GetTextValue(); + } + else + { + UE_LOG(LogSUDSDialogue, + Error, + TEXT("Error in %s: %s was set to a value that was not text, cannot use"), + *BaseScript->GetName(), + *Key.ToString()); + } + } + if (CurrentSpeakerDisplayName.IsEmpty()) + { + // If no display name was specified, use the (non-localised) speaker ID + CurrentSpeakerDisplayName = FText::FromString(GetSpeakerID()); + } + } + return CurrentSpeakerDisplayName; +} + +UDialogueVoice* USUDSDialogue::GetSpeakerVoice() const +{ + if (CurrentSpeakerNode) + { + return GetVoice(CurrentSpeakerNode->GetSpeakerID()); + } + return nullptr; +} + +UDialogueVoice* USUDSDialogue::GetVoice(FString Name) const +{ + return BaseScript->GetSpeakerVoice(Name); +} + +UDialogueVoice* USUDSDialogue::GetTargetVoice() const +{ + if (CurrentSpeakerNode) + { + // Assume that target is the first party that's NOT speaking + for (auto& Name : BaseScript->GetSpeakers()) + { + if (Name != CurrentSpeakerNode->GetSpeakerID()) + { + return BaseScript->GetSpeakerVoice(Name); + } + } + } + return nullptr; + +} + +USoundBase* USUDSDialogue::GetSoundForCurrentLine(bool bAllowAnyTarget) const +{ + // UDialogueWave's contexts have both speakers and targets, but the GetWaveFromContext method is too restrictive + // Instead we'll search the contexts ourselves and be more fuzzy + if (auto Wave = GetWave()) + { + auto SpeakerVoice = GetSpeakerVoice(); + auto TargetVoice = GetTargetVoice(); + for (auto& Ctx : Wave->ContextMappings) + { + // Match specific target voice first, and unspecified targets + if (Ctx.Context.Speaker == SpeakerVoice) + { + if (Ctx.Context.Targets.Contains(TargetVoice)) + { + // Need to use the proxy according to DialogueWave + return Ctx.Proxy; + } + } + } + // If we got here, match more leniently + if (bAllowAnyTarget) + { + for (auto& Ctx : Wave->ContextMappings) + { + // Match specific target voice first, and unspecified targets + if (Ctx.Context.Speaker == SpeakerVoice) + { + // Need to use the proxy according to DialogueWave + return Ctx.Proxy; + } + } + + } + } + + return nullptr; +} + +USoundConcurrency* USUDSDialogue::GetVoiceSoundConcurrency() const +{ + return GetSUDSSubsystem(this->GetWorld())->GetVoicedLineConcurrency(); +} + +void USUDSDialogue::PlayVoicedLine2D(float VolumeMultiplier, float PitchMultiplier, bool bLooselyMatchTarget) +{ + if (auto Sound = GetSoundForCurrentLine(bLooselyMatchTarget)) + { + UGameplayStatics::PlaySound2D(this, Sound, VolumeMultiplier, PitchMultiplier, 0, GetVoiceSoundConcurrency()); + } +} + +void USUDSDialogue::PlayVoicedLineAtLocation(FVector Location, + FRotator Rotation, + float VolumeMultiplier, + float PitchMultiplier, + USoundAttenuation* AttenuationSettings, bool bLooselyMatchTarget) +{ + if (auto Sound = GetSoundForCurrentLine(bLooselyMatchTarget)) + { + UGameplayStatics::PlaySoundAtLocation(this, + Sound, + Location, + Rotation, + VolumeMultiplier, + PitchMultiplier, + 0, + AttenuationSettings, + GetVoiceSoundConcurrency()); + } +} + +UAudioComponent* USUDSDialogue::SpawnVoicedLine2D(float VolumeMultiplier, float PitchMultiplier, bool bLooselyMatchTarget) +{ + if (auto Sound = GetSoundForCurrentLine(bLooselyMatchTarget)) + { + return UGameplayStatics::SpawnSound2D(this, + Sound, + VolumeMultiplier, + PitchMultiplier, + 0, + GetVoiceSoundConcurrency()); + } + + return nullptr; +} + +UAudioComponent* USUDSDialogue::SpawnVoicedLineAtLocation(FVector Location, + FRotator Rotation, + float VolumeMultiplier, + float PitchMultiplier, + USoundAttenuation* AttenuationSettings, + bool bLooselyMatchTarget) +{ + if (auto Sound = GetSoundForCurrentLine(bLooselyMatchTarget)) + { + return UGameplayStatics::SpawnSoundAtLocation(this, + Sound, + Location, + Rotation, + VolumeMultiplier, + PitchMultiplier, + 0, + AttenuationSettings, + GetVoiceSoundConcurrency()); + } + + return nullptr; +} + +USoundBase* USUDSDialogue::GetVoicedLineSound(bool bLooselyMatchTarget) +{ + return GetSoundForCurrentLine(bLooselyMatchTarget); +} + +USUDSScriptNode* USUDSDialogue::GetNextNode(USUDSScriptNode* Node) +{ + // In the case of select or random, we need to evaluate to get the next node + if (Node->GetNodeType() == ESUDSScriptNodeType::Select) + { + return RunSelectNode(Node); + } + else + { + return BaseScript->GetNextNode(Node); + } +} + +bool USUDSDialogue::IsChoiceOrTextNode(ESUDSScriptNodeType Type) +{ + return Type == ESUDSScriptNodeType::Text || Type == ESUDSScriptNodeType::Choice; +} + +const USUDSScriptNode* USUDSDialogue::WalkToNextChoiceNode(USUDSScriptNode* FromNode, bool bExecute) +{ + if (FromNode && FromNode->GetEdgeCount() == 1) + { + const auto NextNode = GetNextNode(FromNode); + TArray> TempGosubStack; + if (!bExecute) + { + // Make a copy of the gosub stack so we can safely explore gosubs + TempGosubStack.Append(GosubReturnStack); + } + + const auto ResultNode = RecurseWalkToNextChoiceOrTextNode(NextNode, bExecute, bExecute ? GosubReturnStack : TempGosubStack); + if (ResultNode && ResultNode->GetNodeType() == ESUDSScriptNodeType::Choice) + { + return ResultNode; + } + } + return nullptr; +} + +USUDSScriptNode* USUDSDialogue::RecurseWalkToNextChoiceOrTextNode(USUDSScriptNode* Node, bool bExecute, TArray>& LocalGosubStack) +{ + auto NextNode = Node; + while (NextNode && !IsChoiceOrTextNode(NextNode->GetNodeType())) + { + // Special case gosub/return in non-execute mode, since only RunNode will explore them + if (!bExecute) + { + if (NextNode->GetNodeType() == ESUDSScriptNodeType::Gosub) + { + // We need to special case Gosubs, since to find the choice we have to go into them and potentially out again + if (USUDSScriptNodeGosub* GosubNode = Cast(NextNode)) + { + if (auto SubNode = BaseScript->GetNodeByLabel(GosubNode->GetLabelName())) + { + LocalGosubStack.Add(GosubNode); + NextNode = RecurseWalkToNextChoiceOrTextNode(SubNode, bExecute, LocalGosubStack); + continue; + } + } + + } + else if (NextNode->GetNodeType() == ESUDSScriptNodeType::Return) + { + if (LocalGosubStack.Num() > 0) + { + // We try to find the next choice node after the gosub, which temporarily redirected + const auto GoSubNode = LocalGosubStack.Pop(); + NextNode = RecurseWalkToNextChoiceOrTextNode(GetNextNode(GoSubNode), bExecute, LocalGosubStack); + continue; + } + else + { + return nullptr; + } + } + } + + if (bExecute) + { + NextNode = RunNode(NextNode); + } + else + { + NextNode = GetNextNode(NextNode); + } + } + + return NextNode; +} + +const USUDSScriptNode* USUDSDialogue::RunUntilNextChoiceNode(USUDSScriptNode* FromNode) +{ + return WalkToNextChoiceNode(FromNode, true); +} +const USUDSScriptNode* USUDSDialogue::FindNextChoiceNode(USUDSScriptNode* FromNode) +{ + return WalkToNextChoiceNode(FromNode, false); +} + +const TArray& USUDSDialogue::GetChoices() const +{ + return CurrentChoices; +} + +void USUDSDialogue::RecurseAppendChoices(const USUDSScriptNode* Node, TArray& OutChoices) +{ + if (!Node) + return; + + // We only cascade into choices or selects + if(Node->GetNodeType() != ESUDSScriptNodeType::Choice && + Node->GetNodeType() != ESUDSScriptNodeType::Select) + { + return; + } + + for (auto& Edge : Node->GetEdges()) + { + switch (Edge.GetType()) + { + case ESUDSEdgeType::Decision: + OutChoices.Add(Edge); + break; + case ESUDSEdgeType::Condition: + // Conditional edges are under selects + if (Edge.GetCondition().IsValid()) + { + RaiseExpressionVariablesRequested(Edge.GetCondition(), Edge.GetSourceLineNo()); + if (Edge.GetCondition().EvaluateBoolean(VariableState, GetGlobalVariables(), BaseScript->GetName())) + { + RecurseAppendChoices(Edge.GetTargetNode().Get(), OutChoices); + // When we choose a path on a select, we don't check the other paths, we can only go down one + return; + } + } + break; + case ESUDSEdgeType::Chained: + RecurseAppendChoices(Edge.GetTargetNode().Get(), OutChoices); + break; + default: + case ESUDSEdgeType::Continue: + UE_LOG(LogSUDSDialogue, Fatal, TEXT("Should not have encountered invalid edge in RecurseAppendChoices")) + break; + }; + + } +} + +void USUDSDialogue::UpdateChoices() +{ + CurrentChoices.Reset(); + CurrentRootChoiceNode = nullptr; + if (CurrentSpeakerNode) + { + // If we've either found choices through static checking (on one or other select paths), we look for them now + // We also check if we're inside a gosub, since the call site changes whether there may be choices or not + if (CurrentSpeakerNode->MayHaveChoices() || + GosubReturnStack.Num() > 0) + { + // We MIGHT have a choice; conditionals can result in HasChoices() being true but the current state not actually + // taking us to a choice path + CurrentRootChoiceNode = FindNextChoiceNode(CurrentSpeakerNode); + if (CurrentRootChoiceNode) + { + // Run any e.g. set nodes between text and choice + // These can be set nodes directly under the text and before the first choice, which get run for all choices + RunUntilNextChoiceNode(CurrentSpeakerNode); + + // Once we've found & run up to the root choice, there can be potentially a tree of mixed choice/select nodes + // for supporting conditional choices + RecurseAppendChoices(CurrentRootChoiceNode, CurrentChoices); + } + } + + if (CurrentChoices.Num() == 0) + { + if (auto Edge = CurrentSpeakerNode->GetEdge(0)) + { + // Simple no-choice progression + // May occur if HasChoices was true but in current state no choice was found + CurrentChoices.Add(*Edge); + } + } + } +} + + +int USUDSDialogue::GetNumberOfChoices() const +{ + return CurrentChoices.Num(); +} + +bool USUDSDialogue::IsSimpleContinue() const +{ + return CurrentChoices.Num() == 1 && CurrentChoices[0].GetText().IsEmpty(); +} + +FText USUDSDialogue::GetChoiceText(int Index) +{ + + if (CurrentChoices.IsValidIndex(Index)) + { + auto& Choice = CurrentChoices[Index]; + if (Choice.HasParameters()) + { + return ResolveParameterisedText(Choice.GetParameterNames(), Choice.GetTextFormat(), Choice.GetSourceLineNo()); + } + else + { + return Choice.GetText(); + } + } + else + { + UE_LOG(LogSUDSDialogue, Error, TEXT("Invalid choice index %d on node %s"), Index, *GetText().ToString()); + } + + return DummyText; +} + +bool USUDSDialogue::HasChoiceIndexBeenTakenPreviously(int Index) +{ + if (CurrentChoices.IsValidIndex(Index)) + { + return HasChoiceBeenTakenPreviously(CurrentChoices[Index]); + } + return false; +} + +bool USUDSDialogue::HasChoiceBeenTakenPreviously(const FSUDSScriptEdge& Choice) +{ + return ChoicesTaken.Contains(Choice.GetTextID()); +} + +bool USUDSDialogue::Continue() +{ + if (GetNumberOfChoices() == 1) + { + return Choose(0); + } + return !IsEnded(); +} + +bool USUDSDialogue::Choose(int Index) +{ + if (CurrentChoices.IsValidIndex(Index)) + { + // ONLY run to choice node if there is one! + // This method is called for Continue() too, which has no choice node + if (CurrentNodeHasChoices()) + { + const auto& Choice = CurrentChoices[Index]; + ChoicesTaken.Add(Choice.GetTextID()); + + RaiseChoiceMade(Index, Choice.GetSourceLineNo()); + RaiseProceeding(); + } + else + { + RaiseProceeding(); + } + // Then choose path + RunUntilNextSpeakerNodeOrEnd(CurrentChoices[Index].GetTargetNode().Get(), true); + return !IsEnded(); + } + else + { + UE_LOG(LogSUDSDialogue, Error, TEXT("Invalid choice index %d on node %s"), Index, *GetText().ToString()); + } + return false; +} + +bool USUDSDialogue::CurrentNodeHasChoices() const +{ + return CurrentRootChoiceNode != nullptr; +} + +bool USUDSDialogue::IsEnded() const +{ + return CurrentSpeakerNode == nullptr; +} + +bool USUDSDialogue::IsFinalLine() const +{ + return CurrentSpeakerNode && CurrentChoices.Num() == 1 && CurrentChoices[0].GetTargetNode() == nullptr; +} + +void USUDSDialogue::End(bool bQuietly) +{ + SetCurrentSpeakerNode(nullptr, bQuietly); +} + +int USUDSDialogue::GetCurrentSourceLine() const +{ + return CurrentSourceLineNo; +} + +void USUDSDialogue::ResetState(bool bResetVariables, bool bResetPosition, bool bResetVisited) +{ + if (bResetVariables) + InitVariables(); + if (bResetPosition) + SetCurrentSpeakerNode(nullptr, true); + if (bResetVisited) + ChoicesTaken.Reset(); +} + +FSUDSDialogueState USUDSDialogue::GetSavedState() const +{ + const FString CurrentNodeId = CurrentSpeakerNode + ? SUDS_GET_TEXT_KEY(CurrentSpeakerNode->GetText()) + : FString(); + + TArray ExportReturnStack; + for (auto Node : GosubReturnStack) + { + if (auto GN = Cast(Node)) + { + ExportReturnStack.Add(GN->GetGosubID()); + } + + } + return FSUDSDialogueState(CurrentNodeId, VariableState, ChoicesTaken, ExportReturnStack); + +} + +void USUDSDialogue::RestoreSavedState(const FSUDSDialogueState& State) +{ + // Don't just empty variables + // Re-run init to ensure header state is initialised then merge; important for it script is altered since state saved + InitVariables(); + VariableState.Append(State.GetVariables()); + ChoicesTaken.Empty(); + ChoicesTaken.Append(State.GetChoicesTaken()); + GosubReturnStack.Empty(); + for (auto ID : State.GetReturnStack()) + { + USUDSScriptNodeGosub* Node = BaseScript->GetNodeByGosubID(ID); + if (!Node) + { + UE_LOG(LogSUDSDialogue, Error, TEXT("Restore: Can't find Gosub with ID %s, returns referencing it will go to end"), *ID); + } + // Add anyway, will just go to end + GosubReturnStack.Add(Node); + } + + // If not found this will be null + if (!State.GetTextNodeID().IsEmpty()) + { + USUDSScriptNodeText* Node = BaseScript->GetNodeByTextID(State.GetTextNodeID()); + SetCurrentSpeakerNode(Node, true); + } + else + { + SetCurrentSpeakerNode(nullptr, true); + } +} + +void USUDSDialogue::Restart(bool bResetState, FName StartLabel, bool bReRunHeader) +{ + if (bResetState) + { + ResetState(); + } + // Always reset return stack + GosubReturnStack.Empty(); + CurrentSourceLineNo = 0; + RaiseStarting(StartLabel); + + if (!bResetState && bReRunHeader) + { + // Run header nodes but don't re-init + RunUntilNextSpeakerNodeOrEnd(BaseScript->GetHeaderNode(), false); + } + + if (StartLabel != NAME_None) + { + // Check that StartLabel leads to a text node + // Labels can lead to choices or select nodes for looping, but there has to be a text node to start with. + auto StartNode = BaseScript->GetNodeByLabel(StartLabel); + if (!StartNode) + { + UE_LOG(LogSUDSDialogue, Error, TEXT("No start label called %s in dialogue %s"), *StartLabel.ToString(), *BaseScript->GetName()); + StartNode = BaseScript->GetFirstNode(); + } + else if (StartNode->GetNodeType() == ESUDSScriptNodeType::Choice) + { + UE_LOG(LogSUDSDialogue, + Error, + TEXT("Label %s in dialogue %s cannot be used as a start point, points to a choice."), + *StartLabel.ToString(), + *BaseScript->GetName()); + StartNode = BaseScript->GetFirstNode(); + } + RunUntilNextSpeakerNodeOrEnd(StartNode, true); + } + else + { + RunUntilNextSpeakerNodeOrEnd(BaseScript->GetFirstNode(), true); + } + +} + + +TSet USUDSDialogue::GetParametersInUse() +{ + // Build on demand, may not be needed + if (!bParamNamesExtracted) + { + CurrentRequestedParamNames.Reset(); + if (CurrentSpeakerNode && CurrentSpeakerNode->HasParameters()) + { + CurrentRequestedParamNames.Append(CurrentSpeakerNode->GetParameterNames()); + } + for (auto& Choice : CurrentChoices) + { + if (Choice.HasParameters()) + { + CurrentRequestedParamNames.Append(Choice.GetParameterNames()); + } + } + bParamNamesExtracted = true; + } + + return CurrentRequestedParamNames; + +} + +void USUDSDialogue::RaiseStarting(FName StartLabel) +{ + for (const auto& P : Participants) + { + if (P->GetClass()->ImplementsInterface(USUDSParticipant::StaticClass())) + { + ISUDSParticipant::Execute_OnDialogueStarting(P, this, StartLabel); + } + } + OnStarting.Broadcast(this, StartLabel); +#if WITH_EDITOR + InternalOnStarting.ExecuteIfBound(this, StartLabel); +#endif +} + +void USUDSDialogue::RaiseFinished() +{ + for (const auto& P : Participants) + { + if (P->GetClass()->ImplementsInterface(USUDSParticipant::StaticClass())) + { + ISUDSParticipant::Execute_OnDialogueFinished(P, this); + } + } + OnFinished.Broadcast(this); +#if WITH_EDITOR + InternalOnFinished.ExecuteIfBound(this); +#endif + +} + +void USUDSDialogue::RaiseNewSpeakerLine() +{ + for (const auto& P : Participants) + { + if (P->GetClass()->ImplementsInterface(USUDSParticipant::StaticClass())) + { + ISUDSParticipant::Execute_OnDialogueSpeakerLine(P, this); + } + } + + // Event listeners get it after + OnSpeakerLine.Broadcast(this); +#if WITH_EDITOR + InternalOnSpeakerLine.ExecuteIfBound(this, GetCurrentSourceLine()); +#endif +} + +void USUDSDialogue::RaiseChoiceMade(int Index, int LineNo) +{ + for (const auto& P : Participants) + { + if (P->GetClass()->ImplementsInterface(USUDSParticipant::StaticClass())) + { + ISUDSParticipant::Execute_OnDialogueChoiceMade(P, this, Index); + } + } + // Event listeners get it after + OnChoice.Broadcast(this, Index); +#if WITH_EDITOR + InternalOnChoice.ExecuteIfBound(this, Index, LineNo); +#endif +} + +void USUDSDialogue::RaiseProceeding() +{ + for (const auto& P : Participants) + { + if (P->GetClass()->ImplementsInterface(USUDSParticipant::StaticClass())) + { + ISUDSParticipant::Execute_OnDialogueProceeding(P, this); + } + } + // Event listeners get it after + OnProceeding.Broadcast(this); +#if WITH_EDITOR + InternalOnProceeding.ExecuteIfBound(this); +#endif +} + +FText USUDSDialogue::GetVariableText(FName Name) const +{ + if (const auto Arg = VariableState.Find(Name)) + { + if (Arg->GetType() == ESUDSValueType::Text) + { + return Arg->GetTextValue(); + } + else + { + UE_LOG(LogSUDSDialogue, Error, TEXT("Requested variable %s of type text but was not a compatible type"), *Name.ToString()); + } + } + return FText(); +} + +void USUDSDialogue::SetVariableInt(FName Name, int32 Value) +{ + SetVariable(Name, Value); +} + +int USUDSDialogue::GetVariableInt(FName Name) const +{ + if (const auto Arg = VariableState.Find(Name)) + { + switch (Arg->GetType()) + { + case ESUDSValueType::Int: + return Arg->GetIntValue(); + case ESUDSValueType::Float: + UE_LOG(LogSUDSDialogue, Warning, TEXT("Casting variable %s to int, data loss may occur"), *Name.ToString()); + return Arg->GetFloatValue(); + default: + case ESUDSValueType::Gender: + case ESUDSValueType::Text: + UE_LOG(LogSUDSDialogue, Error, TEXT("Variable %s is not a compatible integer type"), *Name.ToString()); + } + } + return 0; +} + +void USUDSDialogue::SetVariableFloat(FName Name, float Value) +{ + SetVariable(Name, Value); +} + +float USUDSDialogue::GetVariableFloat(FName Name) const +{ + if (const auto Arg = VariableState.Find(Name)) + { + switch (Arg->GetType()) + { + case ESUDSValueType::Int: + return Arg->GetIntValue(); + case ESUDSValueType::Float: + return Arg->GetFloatValue(); + default: + case ESUDSValueType::Gender: + case ESUDSValueType::Text: + UE_LOG(LogSUDSDialogue, Error, TEXT("Variable %s is not a compatible float type"), *Name.ToString()); + } + } + return 0; +} + +void USUDSDialogue::SetVariableGender(FName Name, ETextGender Value) +{ + SetVariable(Name, Value); +} + +ETextGender USUDSDialogue::GetVariableGender(FName Name) const +{ + if (const auto Arg = VariableState.Find(Name)) + { + switch (Arg->GetType()) + { + case ESUDSValueType::Gender: + return Arg->GetGenderValue(); + default: + case ESUDSValueType::Int: + case ESUDSValueType::Float: + case ESUDSValueType::Text: + UE_LOG(LogSUDSDialogue, Error, TEXT("Variable %s is not a compatible gender type"), *Name.ToString()); + } + } + return ETextGender::Neuter; +} + +void USUDSDialogue::SetVariableBoolean(FName Name, bool Value) +{ + // Use explicit FSUDSValue constructor to avoid default int conversion + SetVariable(Name, FSUDSValue(Value)); +} + +bool USUDSDialogue::GetVariableBoolean(FName Name) const +{ + if (const auto Arg = VariableState.Find(Name)) + { + switch (Arg->GetType()) + { + case ESUDSValueType::Boolean: + return Arg->GetBooleanValue(); + case ESUDSValueType::Int: + return Arg->GetIntValue() != 0; + default: + case ESUDSValueType::Float: + case ESUDSValueType::Gender: + case ESUDSValueType::Text: + UE_LOG(LogSUDSDialogue, Error, TEXT("Variable %s is not a compatible boolean type"), *Name.ToString()); + } + } + return false; +} + +void USUDSDialogue::SetVariableName(FName Name, FName Value) +{ + SetVariable(Name, FSUDSValue(Value, false)); +} + +FName USUDSDialogue::GetVariableName(FName Name) const +{ + if (const auto Arg = VariableState.Find(Name)) + { + if (Arg->GetType() == ESUDSValueType::Name) + { + return Arg->GetNameValue(); + } + else + { + UE_LOG(LogSUDSDialogue, Error, TEXT("Requested variable %s of type text but was not a compatible type"), *Name.ToString()); + } + } + return NAME_None; +} + +void USUDSDialogue::UnSetVariable(FName Name) +{ + VariableState.Remove(Name); +} + +FSUDSValue USUDSDialogue::GetSpeakerLineUserMetadata(FName Key) const +{ + if (CurrentSpeakerNode) + { + if (auto pExpr = CurrentSpeakerNode->GetUserMetadata().Find(Key)) + { + return pExpr->Evaluate(VariableState, GetGlobalVariables()); + } + } + return FSUDSValue(); +} + +TMap USUDSDialogue::GetAllSpeakerLineUserMetadata() const +{ + TMap Ret; + if (CurrentSpeakerNode) + { + const TMap& GlobalVariables = GetGlobalVariables(); + const auto& InMeta = CurrentSpeakerNode->GetUserMetadata(); + for (const auto& Pair : InMeta) + { + Ret.Add(Pair.Key, Pair.Value.Evaluate(VariableState, GlobalVariables)); + } + } + return Ret; +} + +FSUDSValue USUDSDialogue::GetChoiceUserMetadata(int Index, FName Key) const +{ + if (CurrentChoices.IsValidIndex(Index)) + { + if (auto pExpr = CurrentChoices[Index].GetUserMetadata().Find(Key)) + { + return pExpr->Evaluate(VariableState, GetGlobalVariables()); + } + } + return FSUDSValue(); +} + +TMap USUDSDialogue::GetAllChoiceUserMetadata(int Index) const +{ + TMap Ret; + if (CurrentChoices.IsValidIndex(Index)) + { + const TMap& GlobalVariables = GetGlobalVariables(); + const auto& InMeta = CurrentChoices[Index].GetUserMetadata(); + for (const auto& Pair : InMeta) + { + Ret.Add(Pair.Key, Pair.Value.Evaluate(VariableState, GlobalVariables)); + } + } + return Ret; +} diff --git a/Plugins/SUDS/Source/SUDS/Private/SUDSExpression.cpp b/Plugins/SUDS/Source/SUDS/Private/SUDSExpression.cpp new file mode 100644 index 00000000..ec94ef46 --- /dev/null +++ b/Plugins/SUDS/Source/SUDS/Private/SUDSExpression.cpp @@ -0,0 +1,465 @@ +// Copyright Steve Streeting 2022 +// Released under the MIT license https://opensource.org/license/MIT/ +#include "SUDSExpression.h" + +#include "SUDSLibrary.h" +#include "Misc/DefaultValueHelper.h" +#include "Internationalization/Regex.h" + +bool FSUDSExpression::ParseFromString(const FString& Expression, FString* OutParseError) +{ + // Assume invalid until we've parsed something + bIsValid = false; + Queue.Empty(); + VariableNames.Empty(); + SourceString = Expression; + + // Shunting-yard algorithm + // Thanks to Nathan Reed https://www.reedbeta.com/blog/the-shunting-yard-algorithm/ + // We take a natural string, parse it and then turn it into a queue of tokens (operators and operands) + // expressed in Reverse Polish Notation, which can be easily executed later + // Variables are not resolved at this point, only at execution time. + + // Split into individual tokens; sections of the regex are: + // - {Variable} + // - Literal numbers (with or without decimal point, with or without preceding negation) + // - Arithmetic operators & parentheses + // - Boolean operators & comparisons + // - Predefined constants (Masculine, feminine, true, false etc) + // - Quoted strings "string" + // - Including ignoring escaped double quotes + // - Quoted names `name` + const FRegexPattern Pattern(TEXT("(\\{[\\w\\.]+\\}|-?\\d+(?:\\.\\d*)?|[-+*\\/%\\(\\)]|and|&&|\\|\\||or|not|\\<\\>|!=|!|\\<=?|\\>=?|==?|[mM]asculine|[fF]eminine|[nN]euter|[tT]rue|[fF]alse|\"(?:[^\"\\\\]|\\\\.)*\"|`([^`]*)`)")); + FRegexMatcher Regex(Pattern, Expression); + // Stacks that we use to construct + TArray OperatorStack; + bool bParsedSomething = false; + bool bErrors = false; + while (Regex.FindNext()) + { + FString Str = Regex.GetCaptureGroup(1); + ESUDSExpressionItemType OpType = ParseOperator(Str); + if (OpType != ESUDSExpressionItemType::Null) + { + bParsedSomething = true; + + if (OpType == ESUDSExpressionItemType::LParens) + { + OperatorStack.Push(OpType); + } + else if (OpType == ESUDSExpressionItemType::RParens) + { + if (OperatorStack.IsEmpty()) + { + if (OutParseError) + *OutParseError = TEXT("Mismatched parentheses"); + bErrors = true; + break; + } + + while (OperatorStack.Num() > 0 && OperatorStack.Top() != ESUDSExpressionItemType::LParens) + { + Queue.Add(FSUDSExpressionItem(OperatorStack.Pop())); + } + if (OperatorStack.IsEmpty()) + { + if (OutParseError) + *OutParseError = TEXT("Mismatched parentheses"); + bErrors = true; + break; + } + else + { + // Discard left parens + OperatorStack.Pop(); + } + + } + else + { + // All operators are left-associative except not + const bool bLeftAssociative = OpType != ESUDSExpressionItemType::Not; + // Valid operator + // Apply anything on the operator stack which is higher / equal precedence + while (OperatorStack.Num() > 0 && + // higher precedence applied now, and equal precedence if left-associative + (static_cast(OperatorStack.Top()) < static_cast(OpType) || + (static_cast(OperatorStack.Top()) <= static_cast(OpType) && bLeftAssociative))) + { + Queue.Add(FSUDSExpressionItem(OperatorStack.Pop())); + } + + OperatorStack.Push(OpType); + } + } + else + { + // Attempt to parse operand + FSUDSValue Operand; + if (ParseOperand(Str, Operand)) + { + bParsedSomething = true; + Queue.Add(FSUDSExpressionItem(Operand)); + } + else + { + if (OutParseError) + *OutParseError = FString::Printf(TEXT("Unrecognised token %s"), *Str); + bErrors = true; + } + } + } + // finish up + while (OperatorStack.Num() > 0) + { + if (OperatorStack.Top() == ESUDSExpressionItemType::LParens || + OperatorStack.Top() == ESUDSExpressionItemType::RParens) + { + bErrors = true; + if (OutParseError) + *OutParseError = TEXT("Mismatched parentheses"); + break; + } + + Queue.Add(FSUDSExpressionItem(OperatorStack.Pop())); + } + + if (!Validate() || + (Expression.Len() > 0 && Queue.IsEmpty())) // Empty expressions validate correctly, but if there was text incoming that resolved to nothing, this is an error + { + bErrors = true; + if (OutParseError) + *OutParseError = FString::Printf(TEXT("Bad expression '%s'"), *Expression); + } + + bIsValid = bParsedSomething && !bErrors; + + // Build list of variables + if (bIsValid) + { + for (auto& Item : Queue) + { + if (Item.IsOperand() && Item.GetOperandValue().IsVariable()) + { + VariableNames.AddUnique(Item.GetOperandValue().GetVariableNameValue()); + } + } + + } + + return bIsValid; +} + +void FSUDSExpression::Reset() +{ + bIsValid = true; + Queue.Empty(); + VariableNames.Empty(); + SourceString = ""; +} + +bool FSUDSExpression::IsRandomCondition() const +{ + if (Queue.Num() > 0 && Queue[0].GetType() == ESUDSExpressionItemType::Operand) + { + const auto& Operand = Queue[0].GetOperandValue(); + return Operand.IsVariable() && Operand.GetVariableNameValue() == FSUDSConstants::RandomItemSelectIndexVarName; + } + return false; +} + +ESUDSExpressionItemType FSUDSExpression::ParseOperator(const FString& OpStr) +{ + if (OpStr == "+") + return ESUDSExpressionItemType::Add; + if (OpStr == "-") + return ESUDSExpressionItemType::Subtract; + if (OpStr == "*") + return ESUDSExpressionItemType::Multiply; + if (OpStr == "/") + return ESUDSExpressionItemType::Divide; + if (OpStr == "%") + return ESUDSExpressionItemType::Modulo; + if (OpStr == "and" || OpStr == "&&") + return ESUDSExpressionItemType::And; + if (OpStr == "or" || OpStr == "||") + return ESUDSExpressionItemType::Or; + if (OpStr == "not" || OpStr == "!") + return ESUDSExpressionItemType::Not; + if (OpStr == "==" || OpStr == "=") + return ESUDSExpressionItemType::Equal; + if (OpStr == ">=") + return ESUDSExpressionItemType::GreaterEqual; + if (OpStr == ">") + return ESUDSExpressionItemType::Greater; + if (OpStr == "<=") + return ESUDSExpressionItemType::LessEqual; + if (OpStr == "<") + return ESUDSExpressionItemType::Less; + if (OpStr == "<>" || OpStr == "!=") + return ESUDSExpressionItemType::NotEqual; + if (OpStr == "(") + return ESUDSExpressionItemType::LParens; + if (OpStr == ")") + return ESUDSExpressionItemType::RParens; + + return ESUDSExpressionItemType::Null; +} + +bool FSUDSExpression::ParseOperand(const FString& ValueStr, FSUDSValue& OutVal) +{ + // Try Boolean first since only 2 options + { + if (ValueStr.Compare("true", ESearchCase::IgnoreCase) == 0) + { + OutVal = FSUDSValue(true); + return true; + } + if (ValueStr.Compare("false", ESearchCase::IgnoreCase) == 0) + { + OutVal = FSUDSValue(false); + return true; + } + } + // Try gender + { + if (ValueStr.Compare("masculine", ESearchCase::IgnoreCase) == 0) + { + OutVal = FSUDSValue(ETextGender::Masculine); + return true; + } + if (ValueStr.Compare("feminine", ESearchCase::IgnoreCase) == 0) + { + OutVal = FSUDSValue(ETextGender::Feminine); + return true; + } + if (ValueStr.Compare("neuter", ESearchCase::IgnoreCase) == 0) + { + OutVal = FSUDSValue(ETextGender::Neuter); + return true; + } + } + // Try quoted text (will be localised later in asset conversion) + { + const FRegexPattern Pattern(TEXT("^\"((?:[^\"\\\\]|\\\\.)*)\"$")); + FRegexMatcher Regex(Pattern, ValueStr); + if (Regex.FindNext()) + { + FString Val = Regex.GetCaptureGroup(1); + // Consolidate any escaped double quotes into just quotes + Val.ReplaceInline(TEXT("\\\""), TEXT("\"")); + OutVal = FSUDSValue(FText::FromString(Val)); + return true; + } + } + // Try FName + { + const FRegexPattern Pattern(TEXT("^`([^`]*)`$")); + FRegexMatcher Regex(Pattern, ValueStr); + if (Regex.FindNext()) + { + const FString Name = Regex.GetCaptureGroup(1); + OutVal = FSUDSValue(FName(Name), false); + return true; + } + } + // Try variable name + { + const FRegexPattern Pattern(TEXT("^\\{([^\\}]*)\\}$")); + FRegexMatcher Regex(Pattern, ValueStr); + if (Regex.FindNext()) + { + const FName VariableName(Regex.GetCaptureGroup(1)); + OutVal = FSUDSValue(VariableName, true); + return true; + } + } + // Try Numbers + { + float FloatVal; + int IntVal; + // look for int first; anything with a decimal point will fail + if (FDefaultValueHelper::ParseInt(ValueStr, IntVal)) + { + OutVal = FSUDSValue(IntVal); + return true; + } + if (FDefaultValueHelper::ParseFloat(ValueStr, FloatVal)) + { + OutVal = FSUDSValue(FloatVal); + return true; + } + } + + return false; + +} + +bool FSUDSExpression::Validate() +{ + // Empty expressions are always valid, mean "true" + if (Queue.IsEmpty()) + return true; + + // Same algorithm as Execute, we just don't execute + TArray EvalStack; + const TMap TempVariables; + for (auto& Item : Queue) + { + if (Item.IsOperator()) + { + FSUDSExpressionItem Arg1, Arg2; + if (Item.IsBinaryOperator()) + { + if (EvalStack.IsEmpty()) + return false; + Arg2 = EvalStack.Pop(); + } + if (EvalStack.IsEmpty()) + return false; + Arg1 = EvalStack.Pop(); + + EvalStack.Push(EvaluateOperator(Item.GetType(), Arg1, Arg2, TempVariables, TempVariables)); + } + else + { + EvalStack.Push(Item); + } + } + + // Must be one item left and must be an operand + return EvalStack.Num() == 1 && EvalStack[0].IsOperand(); + +} + +FSUDSValue FSUDSExpression::Evaluate(const TMap& Variables, const TMap& GlobalVariables) const +{ + checkf(bIsValid, TEXT("Cannot execute an invalid expression tree")); + + // Blanks are mostly used for conditionals, for simplicity always return true + if (Queue.IsEmpty()) + return FSUDSValue(true); + + TArray EvalStack; + // We could pre-optimise all literal expressions, but let's not for now + for (auto& Item : Queue) + { + if (Item.IsOperator()) + { + FSUDSExpressionItem Arg1, Arg2; + // Arg2 (RHS) has to be popped first + if (Item.IsBinaryOperator()) + { + checkf(!EvalStack.IsEmpty(), TEXT("Args missing before operator, bad expression")); + Arg2 = EvalStack.Pop(); + } + checkf(!EvalStack.IsEmpty(), TEXT("Args missing before operator, bad expression")); + Arg1 = EvalStack.Pop(); + EvalStack.Push(EvaluateOperator(Item.GetType(), Arg1, Arg2, Variables, GlobalVariables)); + } + else + { + EvalStack.Push(Item); + } + } + + checkf(EvalStack.Num() == 1, TEXT("We should end with a single item in the eval stack and it should be an operand")); + + return EvaluateOperand(EvalStack.Top().GetOperandValue(), Variables, GlobalVariables); +} + +bool FSUDSExpression::EvaluateBoolean(const TMap& Variables, const TMap& GlobalVariables, const FString& ErrorContext) const +{ + const auto Result = Evaluate(Variables, GlobalVariables); + + if (Result.GetType() != ESUDSValueType::Boolean && + Result.GetType() != ESUDSValueType::Variable) // Allow unresolved variable, will assume false + { + UE_LOG(LogSUDS, Error, TEXT("%s: Condition '%s' did not return a boolean result"), *ErrorContext, *SourceString) + } + + return Result.GetBooleanValue(); +} + +FSUDSExpressionItem FSUDSExpression::EvaluateOperator(ESUDSExpressionItemType Op, + const FSUDSExpressionItem& Arg1, + const FSUDSExpressionItem& Arg2, + const TMap& Variables, + const TMap& GlobalVariables) const +{ + const FSUDSValue Val1 = EvaluateOperand(Arg1.GetOperandValue(), Variables, GlobalVariables); + FSUDSValue Val2; + if (Arg1.IsBinaryOperator()) + { + Val2 = EvaluateOperand(Arg2.GetOperandValue(), Variables, GlobalVariables); + } + + switch (Op) + { + case ESUDSExpressionItemType::Not: + return FSUDSExpressionItem(!Val1); + case ESUDSExpressionItemType::Multiply: + return FSUDSExpressionItem(Val1 * Val2); + case ESUDSExpressionItemType::Divide: + return FSUDSExpressionItem(Val1 / Val2); + case ESUDSExpressionItemType::Modulo: + return FSUDSExpressionItem(Val1 % Val2); + case ESUDSExpressionItemType::Add: + return FSUDSExpressionItem(Val1 + Val2); + case ESUDSExpressionItemType::Subtract: + return FSUDSExpressionItem(Val1 - Val2); + case ESUDSExpressionItemType::Less: + return FSUDSExpressionItem(Val1 < Val2); + case ESUDSExpressionItemType::LessEqual: + return FSUDSExpressionItem(Val1 <= Val2); + case ESUDSExpressionItemType::Greater: + return FSUDSExpressionItem(Val1 > Val2); + case ESUDSExpressionItemType::GreaterEqual: + return FSUDSExpressionItem(Val1 >= Val2); + case ESUDSExpressionItemType::Equal: + return FSUDSExpressionItem(Val1 == Val2); + case ESUDSExpressionItemType::NotEqual: + return FSUDSExpressionItem(Val1 != Val2); + case ESUDSExpressionItemType::And: + return FSUDSExpressionItem(Val1 && Val2); + case ESUDSExpressionItemType::Or: + return FSUDSExpressionItem(Val1 || Val2); + + + default: // these won't occur + case ESUDSExpressionItemType::Null: + case ESUDSExpressionItemType::Operand: + case ESUDSExpressionItemType::LParens: + case ESUDSExpressionItemType::RParens: + return FSUDSExpressionItem(); + }; + +} + +FSUDSValue FSUDSExpression::EvaluateOperand(const FSUDSValue& Operand, + const TMap& Variables, + const TMap& GlobalVariables) const +{ + // Simplify conversion to variable values + if (Operand.IsVariable()) + { + FName Name = Operand.GetVariableNameValue(); + FName GlobalName; + if (USUDSLibrary::IsDialogueVariableGlobal(Name, GlobalName)) + { + // This will have stripped the prefix so direct find is OK + if (const auto Var = GlobalVariables.Find(GlobalName)) + { + return *Var; + } + } + if (const auto Var = Variables.Find(Operand.GetVariableNameValue())) + { + return *Var; + } + // Note: we're NOT warning about unset variables here, and just defaulting to initial values (false, 0 etc) + // This is more usable in practice than complaining about it + } + + return Operand; +} diff --git a/Plugins/SUDS/Source/SUDS/Private/SUDSInternal.h b/Plugins/SUDS/Source/SUDS/Private/SUDSInternal.h new file mode 100644 index 00000000..cb34a459 --- /dev/null +++ b/Plugins/SUDS/Source/SUDS/Private/SUDSInternal.h @@ -0,0 +1,41 @@ +// Copyright Steve Streeting 2022 +// Released under the MIT license https://opensource.org/license/MIT/ +#pragma once +#include "SUDSSubsystem.h" +#include "SUDSValue.h" + +inline const TMap& InternalGetGlobalVariables(UWorld* WorldContext) +{ + if (auto Sub = GetSUDSSubsystem(WorldContext)) + { + return Sub->GetGlobalVariables(); + } + +#if WITH_EDITORONLY_DATA + // In editor mode, return static global vars since we may be unit testing or in editor tester + return USUDSSubsystem::Test_DummyGlobalVariables; +#else + static TMap Blank; + return Blank; +#endif + +} + +// For our code only +inline void InternalSetGlobalVariable(UWorld* WorldContext, FName Name, const FSUDSValue& Value, bool bFromScript, const FString& ScriptName, int LineNo) +{ + if (auto Sub = GetSUDSSubsystem(WorldContext)) + { + Sub->InternalSetGlobalVariable(Name, Value, bFromScript, LineNo); + } + else + { +#if WITH_EDITORONLY_DATA + // In editor mode, update static global vars since we may be unit testing or in editor tester + USUDSSubsystem::Test_DummyGlobalVariables.Add(Name, Value); +#else + checkf(false, TEXT("%s: Attempted to set global variable %s with no world context, did you forget to set the dialogue owner?"), *ScriptName, *Name.ToString()); +#endif + } +} + diff --git a/Plugins/SUDS/Source/SUDS/Private/SUDSLibrary.cpp b/Plugins/SUDS/Source/SUDS/Private/SUDSLibrary.cpp new file mode 100644 index 00000000..0812e68a --- /dev/null +++ b/Plugins/SUDS/Source/SUDS/Private/SUDSLibrary.cpp @@ -0,0 +1,154 @@ +// Copyright Steve Streeting 2022 +// Released under the MIT license https://opensource.org/license/MIT/ +#include "SUDSLibrary.h" + +#include "SUDSDialogue.h" +#include "SUDSScript.h" +#include "UObject/Package.h" + +USUDSDialogue* USUDSLibrary::CreateDialogue(UObject* Owner, USUDSScript* Script, bool bStartImmediately, FName StartLabel) +{ + if (IsValid(Script)) + { + if (!IsValid(Owner)) + { + Owner = GetTransientPackage(); + } + const FName Name = MakeUniqueObjectName(Owner, USUDSDialogue::StaticClass(), Script->GetFName()); + USUDSDialogue* Ret = NewObject(Owner, Name); + Ret->Initialise(Script); + if (bStartImmediately) + { + Ret->Start(StartLabel); + } + return Ret; + } + UE_LOG(LogSUDS, Error, TEXT("Called CreateDialogue with an invalid script")) + return nullptr; +} + +USUDSDialogue* USUDSLibrary::CreateDialogueWithParticipants(UObject* Owner, + USUDSScript* Script, + const TArray& Participants, bool bStartImmediately, FName StartLabel) +{ + if (IsValid(Script)) + { + if (!IsValid(Owner)) + { + Owner = GetTransientPackage(); + } + // Don't use the base CreateDialogue to start, we want to set participants first before init/start + USUDSDialogue* Dlg = NewObject(Owner, Script->GetFName()); + Dlg->SetParticipants(Participants); + Dlg->Initialise(Script); + if (bStartImmediately) + { + Dlg->Start(StartLabel); + } + return Dlg; + } + UE_LOG(LogSUDS, Error, TEXT("Called CreateDialogue with an invalid script")) + return nullptr; + + +} + +USUDSDialogue* USUDSLibrary::CreateDialogueWithParticipant(UObject* Owner, + USUDSScript* Script, + UObject* Participant, + bool bStartImmediately, + FName StartLabel) +{ + TArray Participants; + Participants.Add(Participant); + return CreateDialogueWithParticipants(Owner, Script, Participants, bStartImmediately, StartLabel); +} + +bool USUDSLibrary::GetDialogueValueAsText(const FSUDSValue& Value, FText& TextValue) +{ + if (Value.GetType() == ESUDSValueType::Text) + { + TextValue = Value.GetTextValue(); + return true; + } + return false; +} + +bool USUDSLibrary::GetDialogueValueAsBoolean(const FSUDSValue& Value, bool& BoolValue) +{ + if (Value.GetType() == ESUDSValueType::Boolean) + { + BoolValue = Value.GetBooleanValue(); + return true; + } + return false; +} + +bool USUDSLibrary::GetDialogueValueAsInt(const FSUDSValue& Value, int& IntValue) +{ + if (Value.GetType() == ESUDSValueType::Int) + { + IntValue = Value.GetIntValue(); + return true; + } + return false; +} + +bool USUDSLibrary::GetDialogueValueAsFloat(const FSUDSValue& Value, float& FloatValue) +{ + if (Value.GetType() == ESUDSValueType::Float) + { + FloatValue = Value.GetFloatValue(); + return true; + } + return false; +} + +bool USUDSLibrary::GetDialogueValueAsGender(const FSUDSValue& Value, ETextGender& GenderValue) +{ + if (Value.GetType() == ESUDSValueType::Gender) + { + GenderValue = Value.GetGenderValue(); + return true; + } + return false; +} + +bool USUDSLibrary::GetDialogueValueAsName(const FSUDSValue& Value, FName& NameValue) +{ + if (Value.GetType() == ESUDSValueType::Name) + { + NameValue = Value.GetNameValue(); + return true; + } + return false; + +} + +ESUDSValueType USUDSLibrary::GetDialogueValueType(const FSUDSValue& Value) +{ + return Value.GetType(); +} + +bool USUDSLibrary::GetDialogueValueIsEmpty(const FSUDSValue& Value) +{ + return Value.IsEmpty(); +} + +bool USUDSLibrary::IsDialogueVariableGlobal(const FName& Name, FName& OutName) +{ + static const FString Prefix(TEXT("global.")); + FString TempStr; + Name.ToString(TempStr); + if (TempStr.StartsWith(Prefix, ESearchCase::IgnoreCase)) + { + TempStr.RightChopInline(Prefix.Len()); + OutName = FName(TempStr); + return true; + } + else + { + OutName = Name; + return false; + } +} diff --git a/Plugins/SUDS/Source/SUDS/Private/SUDSParticipant.cpp b/Plugins/SUDS/Source/SUDS/Private/SUDSParticipant.cpp new file mode 100644 index 00000000..9daf12d7 --- /dev/null +++ b/Plugins/SUDS/Source/SUDS/Private/SUDSParticipant.cpp @@ -0,0 +1,5 @@ +// Copyright Steve Streeting 2022 +// Released under the MIT license https://opensource.org/license/MIT/ +#include "SUDSParticipant.h" + + diff --git a/Plugins/SUDS/Source/SUDS/Private/SUDSScript.cpp b/Plugins/SUDS/Source/SUDS/Private/SUDSScript.cpp new file mode 100644 index 00000000..b6be5d43 --- /dev/null +++ b/Plugins/SUDS/Source/SUDS/Private/SUDSScript.cpp @@ -0,0 +1,276 @@ +// Copyright Steve Streeting 2022 +// Released under the MIT license https://opensource.org/license/MIT/ +#include "SUDSScript.h" + +#include "SUDSScriptNode.h" +#include "SUDSScriptNodeGosub.h" +#include "SUDSScriptNodeText.h" +#include "EditorFramework/AssetImportData.h" + +void USUDSScript::StartImport(TArray>** ppNodes, + TArray>** ppHeaderNodes, + TMap** ppLabelList, + TMap** ppHeaderLabelList, + TArray** ppSpeakerList) +{ + *ppNodes = &Nodes; + *ppHeaderNodes = &HeaderNodes; + *ppLabelList = &LabelList; + *ppHeaderLabelList = &HeaderLabelList; + *ppSpeakerList = &Speakers; +} + +USUDSScriptNode* USUDSScript::GetNextNode(const USUDSScriptNode* Node) const +{ + switch (Node->GetEdgeCount()) + { + case 0: + return nullptr; + case 1: + return Node->GetEdge(0)->GetTargetNode().Get(); + default: + UE_LOG(LogSUDS, Error, TEXT("Called GetNextNode on a node with more than one edge")); + return nullptr; + } + +} + + +#define kChoiceFound 1 +#define kChoiceNotFoundBeforeText -1 +#define kChoiceNotFoundBeforeEnd 0 + + +bool USUDSScript::DoesAnyPathAfterLeadToChoice(USUDSScriptNode* FromNode) +{ + // Look for any possible choice following a node (text or gosub) + // If it's possible to find a choice in one of the paths ahead, before another text node, the return true + // Given that there might be conditionals, not all paths might lead to a choice, but we only care if one of them does + // We recurse into conditional paths where they exist until we know. + // For a gosub this is looking for the next after a return, not inside the sub + USUDSScriptNode* CurrNode = GetNextNode(FromNode); + + return RecurseLookForChoice(CurrNode) == kChoiceFound; +} + + +int USUDSScript::RecurseLookForChoice(USUDSScriptNode* CurrNode) +{ + // Return int so that we can differentiate: + // 1 = we found a choice + // 0 = we didn't find a choice, but also didn't hit another text node (reached end, or gosub return) + // -1 = we hit a text node + while (CurrNode) + { + switch (CurrNode->GetNodeType()) + { + case ESUDSScriptNodeType::Text: + // if we hit a text node, there was no choice + return kChoiceNotFoundBeforeText; + case ESUDSScriptNodeType::Choice: + // we found a choice + return kChoiceFound; + case ESUDSScriptNodeType::Select: + { + // Explore all possible routes + int WorstResult = kChoiceNotFoundBeforeEnd; + for (auto& Edge : CurrNode->GetEdges()) + { + auto TargetNode = Edge.GetTargetNode(); + if (TargetNode.IsValid()) + { + const int ConditionalPath = RecurseLookForChoice(TargetNode.Get()); + if (ConditionalPath == kChoiceFound) + return kChoiceFound; + WorstResult = FMath::Min(ConditionalPath, WorstResult); + } + } + return WorstResult; + } + case ESUDSScriptNodeType::Event: + case ESUDSScriptNodeType::SetVariable: + CurrNode = GetNextNode(CurrNode); + break; + case ESUDSScriptNodeType::Gosub: + // When we hit a gosub here we go into it, not after it + if (auto GosubNode = Cast(CurrNode)) + { + int SubResult = RecurseLookForChoice(GetNodeByLabel(GosubNode->GetLabelName())); + if (SubResult != 0) + { + // Found definitive result (choice or text) inside sub + return SubResult; + } + } + // Otherwise, we didn't conclude within the sub, continue following it + CurrNode = GetNextNode(CurrNode); + break; + default: ; + case ESUDSScriptNodeType::Return: + // this is when we're exploring a sub for the choice + return kChoiceNotFoundBeforeEnd; + }; + } + + return kChoiceNotFoundBeforeEnd; +} + +void USUDSScript::FinishImport() +{ + // As an optimisation, make all text/gosub nodes pre-scan their follow-on nodes for choice nodes + // We can actually have intermediate nodes, for example set nodes which run for all choices that are placed + // between the text and the first choice. Resolve whether they exist now + for (auto Node : Nodes) + { + if (Node->GetNodeType() == ESUDSScriptNodeType::Text || + Node->GetNodeType() == ESUDSScriptNodeType::Gosub) + { + if (DoesAnyPathAfterLeadToChoice(Node)) + { + switch (Node->GetNodeType()) + { + case ESUDSScriptNodeType::Text: + { + if (auto TextNode = Cast(Node)) + { + TextNode->NotifyMayHaveChoices(); + } + break; + } + case ESUDSScriptNodeType::Gosub: + { + if (auto GosubNode = Cast(Node)) + { + GosubNode->NotifyMayHaveChoices(); + } + break; + } + default: break; + } + } + } + } + +} + +USUDSScriptNode* USUDSScript::GetHeaderNode() const +{ + if (HeaderNodes.Num() > 0) + return HeaderNodes[0]; + + return nullptr; +} + +USUDSScriptNode* USUDSScript::GetFirstNode() const +{ + if (Nodes.Num() > 0) + return Nodes[0]; + + return nullptr; +} + +USUDSScriptNode* USUDSScript::GetNodeByLabel(const FName& Label) const +{ + if (const int* pIdx = LabelList.Find(Label)) + { + return Nodes[*pIdx]; + } + + return nullptr; + +} + +USUDSScriptNodeText* USUDSScript::GetNodeByTextID(const FString& TextID) const +{ + for (auto N : Nodes) + { + if (N->GetNodeType() == ESUDSScriptNodeType::Text) + { + if (auto TN = Cast(N)) + { + if (TextID.Equals(TN->GetTextID())) + { + return TN; + } + } + } + } + return nullptr; +} + +USUDSScriptNodeGosub* USUDSScript::GetNodeByGosubID(const FString& ID) const +{ + for (auto N : Nodes) + { + if (N->GetNodeType() == ESUDSScriptNodeType::Text) + { + if (auto GN = Cast(N)) + { + if (ID.Equals(GN->GetGosubID())) + { + return GN; + } + } + } + } + return nullptr; +} + +UDialogueVoice* USUDSScript::GetSpeakerVoice(const FString& SpeakerID) const +{ + if (auto pVoice = SpeakerVoices.Find(SpeakerID)) + { + return *pVoice; + } + return nullptr; +} + +void USUDSScript::SetSpeakerVoice(const FString& SpeakerID, UDialogueVoice* Voice) +{ + SpeakerVoices.Add(SpeakerID, Voice); +} + +#if WITH_EDITORONLY_DATA + +void USUDSScript::PostInitProperties() +{ + if (!HasAnyFlags(RF_ClassDefaultObject)) + { + AssetImportData = NewObject(this, TEXT("AssetImportData")); + } + Super::PostInitProperties(); +} + +#if ENGINE_MAJOR_VERSION == 5 && ENGINE_MINOR_VERSION >= 4 +void USUDSScript::GetAssetRegistryTags(FAssetRegistryTagsContext Context) const +{ + if (AssetImportData) + { + Context.AddTag( FAssetRegistryTag(SourceFileTagName(), AssetImportData->GetSourceData().ToJson(), FAssetRegistryTag::TT_Hidden) ); + } + + Super::GetAssetRegistryTags(Context); +} +#else +void USUDSScript::GetAssetRegistryTags(TArray& OutTags) const +{ + if (AssetImportData) + { + OutTags.Add( FAssetRegistryTag(SourceFileTagName(), AssetImportData->GetSourceData().ToJson(), FAssetRegistryTag::TT_Hidden) ); + } + + Super::GetAssetRegistryTags(OutTags); +} +#endif + +void USUDSScript::Serialize(FArchive& Ar) +{ + Super::Serialize(Ar); + + if (Ar.IsLoading() && Ar.UEVer() < VER_UE4_ASSET_IMPORT_DATA_AS_JSON && !AssetImportData) + { + // AssetImportData should always be valid + AssetImportData = NewObject(this, TEXT("AssetImportData")); + } +} +#endif diff --git a/Plugins/SUDS/Source/SUDS/Private/SUDSScriptEdge.cpp b/Plugins/SUDS/Source/SUDS/Private/SUDSScriptEdge.cpp new file mode 100644 index 00000000..c95e5a7a --- /dev/null +++ b/Plugins/SUDS/Source/SUDS/Private/SUDSScriptEdge.cpp @@ -0,0 +1,79 @@ +// Copyright Steve Streeting 2022 +// Released under the MIT license https://opensource.org/license/MIT/ +#include "SUDSScriptEdge.h" + +#include "SUDSScriptNode.h" + + +FSUDSScriptEdge::FSUDSScriptEdge(USUDSScriptNode* ToNode, ESUDSEdgeType InType, int LineNo): Type(InType), + TargetNode(ToNode), + SourceLineNo(LineNo) +{ +} + +FSUDSScriptEdge::FSUDSScriptEdge(const FText& InText, USUDSScriptNode* ToNode, int LineNo): Text(InText), + Type(ESUDSEdgeType::Decision), + TargetNode(ToNode), + SourceLineNo(LineNo) +{ +} + +void FSUDSScriptEdge::ExtractFormat() const +{ + // Only do this on demand, and only once + TextFormat = Text; + ParameterNames.Empty(); + TArray TextParams; + TextFormat.GetFormatArgumentNames(TextParams); + for (auto Param : TextParams) + { + ParameterNames.Add(FName(Param)); + } + bFormatExtracted = true; +} + +FString FSUDSScriptEdge::GetTextID() const +{ + return SUDS_GET_TEXT_KEY(Text); +} + +void FSUDSScriptEdge::SetText(const FText& InText) +{ + Text = InText; + bFormatExtracted = false; +} + +void FSUDSScriptEdge::SetTargetNode(const TWeakObjectPtr& InTargetNode) +{ + TargetNode = InTargetNode; +} + +const FTextFormat& FSUDSScriptEdge::GetTextFormat() const +{ + if (!bFormatExtracted) + { + ExtractFormat(); + } + return TextFormat; + +} + +const TArray& FSUDSScriptEdge::GetParameterNames() const +{ + if (!bFormatExtracted) + { + ExtractFormat(); + } + return ParameterNames; + +} + +bool FSUDSScriptEdge::HasParameters() const +{ + if (!bFormatExtracted) + { + ExtractFormat(); + } + return !ParameterNames.IsEmpty(); + +} diff --git a/Plugins/SUDS/Source/SUDS/Private/SUDSScriptNode.cpp b/Plugins/SUDS/Source/SUDS/Private/SUDSScriptNode.cpp new file mode 100644 index 00000000..6a7b33ce --- /dev/null +++ b/Plugins/SUDS/Source/SUDS/Private/SUDSScriptNode.cpp @@ -0,0 +1,38 @@ +// Copyright Steve Streeting 2022 +// Released under the MIT license https://opensource.org/license/MIT/ +#include "SUDSScriptNode.h" + +USUDSScriptNode::USUDSScriptNode() +{ +} + +void USUDSScriptNode::InitChoice(int LineNo) +{ + NodeType = ESUDSScriptNodeType::Choice; + SourceLineNo = LineNo; +} + +void USUDSScriptNode::InitSelect(int LineNo) +{ + NodeType = ESUDSScriptNodeType::Select; + SourceLineNo = LineNo; +} + +void USUDSScriptNode::InitReturn(int LineNo) +{ + NodeType = ESUDSScriptNodeType::Return; + SourceLineNo = LineNo; +} + +bool USUDSScriptNode::IsRandomSelect() const +{ + return NodeType == ESUDSScriptNodeType::Select && + GetEdgeCount() > 0 && + GetEdge(0)->GetCondition().IsRandomCondition(); +} + +void USUDSScriptNode::AddEdge(const FSUDSScriptEdge& NewEdge) +{ + Edges.Add(NewEdge); +} + diff --git a/Plugins/SUDS/Source/SUDS/Private/SUDSScriptNodeEvent.cpp b/Plugins/SUDS/Source/SUDS/Private/SUDSScriptNodeEvent.cpp new file mode 100644 index 00000000..de209333 --- /dev/null +++ b/Plugins/SUDS/Source/SUDS/Private/SUDSScriptNodeEvent.cpp @@ -0,0 +1,12 @@ +// Copyright Steve Streeting 2022 +// Released under the MIT license https://opensource.org/license/MIT/ +#include "SUDSScriptNodeEvent.h" + +void USUDSScriptNodeEvent::Init(const FString& EvtName, const TArray& InArgs, int LineNo) +{ + NodeType = ESUDSScriptNodeType::Event; + EventName = FName(EvtName); + Args = InArgs; + SourceLineNo = LineNo; + +} diff --git a/Plugins/SUDS/Source/SUDS/Private/SUDSScriptNodeSet.cpp b/Plugins/SUDS/Source/SUDS/Private/SUDSScriptNodeSet.cpp new file mode 100644 index 00000000..e614726f --- /dev/null +++ b/Plugins/SUDS/Source/SUDS/Private/SUDSScriptNodeSet.cpp @@ -0,0 +1,11 @@ +// Copyright Steve Streeting 2022 +// Released under the MIT license https://opensource.org/license/MIT/ +#include "SUDSScriptNodeSet.h" + +void USUDSScriptNodeSet::Init(const FString& VarName, const FSUDSExpression& InExpression, int LineNo) +{ + NodeType = ESUDSScriptNodeType::SetVariable; + Identifier = FName(VarName); + Expression = InExpression; + SourceLineNo = LineNo; +} diff --git a/Plugins/SUDS/Source/SUDS/Private/SUDSScriptNodeText.cpp b/Plugins/SUDS/Source/SUDS/Private/SUDSScriptNodeText.cpp new file mode 100644 index 00000000..4edbf34d --- /dev/null +++ b/Plugins/SUDS/Source/SUDS/Private/SUDSScriptNodeText.cpp @@ -0,0 +1,62 @@ +// Copyright Steve Streeting 2022 +// Released under the MIT license https://opensource.org/license/MIT/ +#include "SUDSScriptNodeText.h" + +void USUDSScriptNodeText::Init(const FString& InSpeakerID, const FText& InText, int LineNo) +{ + NodeType = ESUDSScriptNodeType::Text; + SpeakerID = InSpeakerID; + Text = InText; + TextFormat = Text; + SourceLineNo = LineNo; + bFormatExtracted = false; + +} + +FString USUDSScriptNodeText::GetTextID() const +{ + return SUDS_GET_TEXT_KEY(Text); +} + +const FTextFormat& USUDSScriptNodeText::GetTextFormat() const +{ + if (!bFormatExtracted) + { + ExtractFormat(); + } + return TextFormat; +} + +const TArray& USUDSScriptNodeText::GetParameterNames() const +{ + if (!bFormatExtracted) + { + ExtractFormat(); + } + return ParameterNames; +} + +bool USUDSScriptNodeText::HasParameters() const +{ + if (!bFormatExtracted) + { + ExtractFormat(); + } + return !ParameterNames.IsEmpty(); + +} + +void USUDSScriptNodeText::ExtractFormat() const +{ + // Only do this on demand, and only once + TextFormat = Text; + ParameterNames.Empty(); + + TArray TextParams; + TextFormat.GetFormatArgumentNames(TextParams); + for (auto Param : TextParams) + { + ParameterNames.Add(FName(Param)); + } + bFormatExtracted = true; +} diff --git a/Plugins/SUDS/Source/SUDS/Private/SUDSSubsystem.cpp b/Plugins/SUDS/Source/SUDS/Private/SUDSSubsystem.cpp new file mode 100644 index 00000000..4e7cbde4 --- /dev/null +++ b/Plugins/SUDS/Source/SUDS/Private/SUDSSubsystem.cpp @@ -0,0 +1,199 @@ +// Copyright Steve Streeting 2022 +// Released under the MIT license https://opensource.org/license/MIT/ +#include "SUDSSubsystem.h" +#include "Sound/SoundConcurrency.h" + +DEFINE_LOG_CATEGORY(LogSUDSSubsystem) + +#if WITH_EDITORONLY_DATA + TMap USUDSSubsystem::Test_DummyGlobalVariables; +#endif + +void USUDSSubsystem::Initialize(FSubsystemCollectionBase& Collection) +{ + Super::Initialize(Collection); + + // Default to a single voice line being played at once + VoiceConcurrency = NewObject(this); + VoiceConcurrency->Concurrency.MaxCount = 1; +} + +void USUDSSubsystem::Deinitialize() +{ + Super::Deinitialize(); +} + +void USUDSSubsystem::SetMaxConcurrentVoicedLines(int ConcurrentLines) +{ + if (IsValid(VoiceConcurrency)) + { + VoiceConcurrency->Concurrency.MaxCount = ConcurrentLines; + } +} + +int USUDSSubsystem::GetMaxConcurrentVoicedLines() const +{ + if (IsValid(VoiceConcurrency)) + { + return VoiceConcurrency->Concurrency.MaxCount; + } + return 1; +} + +void USUDSSubsystem::ResetGlobalState(bool bResetVariables) +{ + if (bResetVariables) + GlobalVariableState.Empty(); +} + +FSUDSGlobalState USUDSSubsystem::GetSavedGlobalState() const +{ + return FSUDSGlobalState(GlobalVariableState); +} + +void USUDSSubsystem::RestoreSavedGlobalState(const FSUDSGlobalState& State) +{ + ResetGlobalState(); + GlobalVariableState.Append(State.GetGlobalVariables()); +} + + +FText USUDSSubsystem::GetGlobalVariableText(FName Name) const +{ + if (const auto Arg = GlobalVariableState.Find(Name)) + { + if (Arg->GetType() == ESUDSValueType::Text) + { + return Arg->GetTextValue(); + } + else + { + UE_LOG(LogSUDSSubsystem, Error, TEXT("Requested variable %s of type text but was not a compatible type"), *Name.ToString()); + } + } + return FText(); +} + +void USUDSSubsystem::SetGlobalVariableInt(FName Name, int32 Value) +{ + SetGlobalVariable(Name, Value); +} + +int USUDSSubsystem::GetGlobalVariableInt(FName Name) const +{ + if (const auto Arg = GlobalVariableState.Find(Name)) + { + switch (Arg->GetType()) + { + case ESUDSValueType::Int: + return Arg->GetIntValue(); + case ESUDSValueType::Float: + UE_LOG(LogSUDSSubsystem, Warning, TEXT("Casting variable %s to int, data loss may occur"), *Name.ToString()); + return Arg->GetFloatValue(); + default: + case ESUDSValueType::Gender: + case ESUDSValueType::Text: + UE_LOG(LogSUDSSubsystem, Error, TEXT("Variable %s is not a compatible integer type"), *Name.ToString()); + } + } + return 0; +} + +void USUDSSubsystem::SetGlobalVariableFloat(FName Name, float Value) +{ + SetGlobalVariable(Name, Value); +} + +float USUDSSubsystem::GetGlobalVariableFloat(FName Name) const +{ + if (const auto Arg = GlobalVariableState.Find(Name)) + { + switch (Arg->GetType()) + { + case ESUDSValueType::Int: + return Arg->GetIntValue(); + case ESUDSValueType::Float: + return Arg->GetFloatValue(); + default: + case ESUDSValueType::Gender: + case ESUDSValueType::Text: + UE_LOG(LogSUDSSubsystem, Error, TEXT("Variable %s is not a compatible float type"), *Name.ToString()); + } + } + return 0; +} + +void USUDSSubsystem::SetGlobalVariableGender(FName Name, ETextGender Value) +{ + SetGlobalVariable(Name, Value); +} + +ETextGender USUDSSubsystem::GetGlobalVariableGender(FName Name) const +{ + if (const auto Arg = GlobalVariableState.Find(Name)) + { + switch (Arg->GetType()) + { + case ESUDSValueType::Gender: + return Arg->GetGenderValue(); + default: + case ESUDSValueType::Int: + case ESUDSValueType::Float: + case ESUDSValueType::Text: + UE_LOG(LogSUDSSubsystem, Error, TEXT("Variable %s is not a compatible gender type"), *Name.ToString()); + } + } + return ETextGender::Neuter; +} + +void USUDSSubsystem::SetGlobalVariableBoolean(FName Name, bool Value) +{ + // Use explicit FSUDSValue constructor to avoid default int conversion + SetGlobalVariable(Name, FSUDSValue(Value)); +} + +bool USUDSSubsystem::GetGlobalVariableBoolean(FName Name) const +{ + if (const auto Arg = GlobalVariableState.Find(Name)) + { + switch (Arg->GetType()) + { + case ESUDSValueType::Boolean: + return Arg->GetBooleanValue(); + case ESUDSValueType::Int: + return Arg->GetIntValue() != 0; + default: + case ESUDSValueType::Float: + case ESUDSValueType::Gender: + case ESUDSValueType::Text: + UE_LOG(LogSUDSSubsystem, Error, TEXT("Variable %s is not a compatible boolean type"), *Name.ToString()); + } + } + return false; +} + +void USUDSSubsystem::SetGlobalVariableName(FName Name, FName Value) +{ + SetGlobalVariable(Name, FSUDSValue(Value, false)); +} + +FName USUDSSubsystem::GetGlobalVariableName(FName Name) const +{ + if (const auto Arg = GlobalVariableState.Find(Name)) + { + if (Arg->GetType() == ESUDSValueType::Name) + { + return Arg->GetNameValue(); + } + else + { + UE_LOG(LogSUDSSubsystem, Error, TEXT("Requested variable %s of type text but was not a compatible type"), *Name.ToString()); + } + } + return NAME_None; +} + +void USUDSSubsystem::UnSetGlobalVariable(FName Name) +{ + GlobalVariableState.Remove(Name); +} diff --git a/Plugins/SUDS/Source/SUDS/Private/SUDSValue.cpp b/Plugins/SUDS/Source/SUDS/Private/SUDSValue.cpp new file mode 100644 index 00000000..0782e31f --- /dev/null +++ b/Plugins/SUDS/Source/SUDS/Private/SUDSValue.cpp @@ -0,0 +1,100 @@ +// Copyright Steve Streeting 2022 +// Released under the MIT license https://opensource.org/license/MIT/ +#include "SUDSValue.h" + +#include "UObject/Class.h" +#include "Internationalization/Text.h" + +FArchive& operator<<(FArchive& Ar, FSUDSValue& Value) +{ + // Custom serialisation since we can't auto-serialise union, TOptional + uint8 TypeAsInt = (uint8)Value.Type; + Ar << TypeAsInt; + if (Ar.IsLoading()) + Value.Type = static_cast(TypeAsInt); + + // This gets/sets float value too + Ar << Value.IntValue; + + if (Value.Type == ESUDSValueType::Text) + { + FText Text = Value.TextValue.Get(FText::GetEmpty()); + Ar << Text; + if (Ar.IsLoading()) + Value.TextValue = Text; + } + else if (Value.Type == ESUDSValueType::Variable || Value.Type == ESUDSValueType::Name) + { + FString VarNameStr = Value.Name.Get(NAME_None).ToString(); + Ar << VarNameStr; + if (Ar.IsLoading()) + Value.Name = FName(VarNameStr); + } + + return Ar; +} + +void operator<<(FStructuredArchive::FSlot Slot, FSUDSValue& Value) +{ + FStructuredArchive::FRecord Record = Slot.EnterRecord(); + Record + << SA_VALUE(TEXT("Type"), Value.Type) + << SA_VALUE(TEXT("IntValue"), Value.IntValue); // gets/sets float/boolean/gender too + + if (Value.Type == ESUDSValueType::Text) + { + Record << SA_VALUE(TEXT("TextValue"), Value.TextValue); + } + else if (Value.Type == ESUDSValueType::Variable || Value.Type == ESUDSValueType::Name) + { + Record << SA_VALUE(TEXT("Name"), Value.Name); + } + +} + +FString FSUDSValue::ToString() const +{ + switch (Type) + { + case ESUDSValueType::Text: + return GetTextValue().ToString(); + case ESUDSValueType::Int: + return FString::FromInt(GetIntValue()); + case ESUDSValueType::Float: + return FString::SanitizeFloat(GetFloatValue()); + case ESUDSValueType::Boolean: + return GetBooleanValue() ? "True" : "False"; + case ESUDSValueType::Gender: +#if ENGINE_MAJOR_VERSION ==5 && ENGINE_MINOR_VERSION >= 8 + return LexToString(GetGenderValue()); +#else + return *StaticEnum()->GetNameStringByValue((int64)GetGenderValue()); +#endif + case ESUDSValueType::Name: + return GetNameValue().ToString(); + case ESUDSValueType::Variable: + return GetVariableNameValue().ToString(); + default: + case ESUDSValueType::Empty: + return "Empty"; + } +} + +bool FSUDSValue::ExportTextItem(FString& ValueStr, + FSUDSValue const& DefaultValue, + UObject* Parent, + int32 PortFlags, + UObject* ExportRootScope) const +{ + // This is used to generate the blueprint debugger, but also used in serialisation + // We need to only implement it for debugging to avoid breaking anything else + if (0 != (PortFlags & EPropertyPortFlags::PPF_BlueprintDebugView)) + { + ValueStr.Appendf(TEXT("Type=%s Value=%s"), *StaticEnum()->GetDisplayValueAsText(Type).ToString(), *ToString()); + return true; + } + + // Use the default for everything else + return false; + +} diff --git a/Plugins/SUDS/Source/SUDS/Public/SUDS.h b/Plugins/SUDS/Source/SUDS/Public/SUDS.h new file mode 100644 index 00000000..589f92d3 --- /dev/null +++ b/Plugins/SUDS/Source/SUDS/Public/SUDS.h @@ -0,0 +1,15 @@ +// Copyright Steve Streeting 2022 +// Released under the MIT license https://opensource.org/license/MIT/ +#pragma once + +#include "CoreMinimal.h" +#include "Modules/ModuleInterface.h" + +class FSUDSModule : public IModuleInterface +{ +public: + + /** IModuleInterface implementation */ + virtual void StartupModule() override; + virtual void ShutdownModule() override; +}; diff --git a/Plugins/SUDS/Source/SUDS/Public/SUDSCommon.h b/Plugins/SUDS/Source/SUDS/Public/SUDSCommon.h new file mode 100644 index 00000000..793c18f1 --- /dev/null +++ b/Plugins/SUDS/Source/SUDS/Public/SUDSCommon.h @@ -0,0 +1,23 @@ +// Copyright Steve Streeting 2022 +// Released under the MIT license https://opensource.org/license/MIT/ +#pragma once +#include "CoreMinimal.h" +#include "Runtime/Launch/Resources/Version.h" + +// Use DECLARE_LOG_CATEGORY_CLASS not DECLARE_LOG_CATEGORY_EXTERN because we use UE_LOG in headers +DECLARE_LOG_CATEGORY_CLASS(LogSUDS, Warning, All) + +#define SUDS_RANDOMITEM_VAR "SUDS.RandomItem" + +struct FSUDSConstants +{ + /// Reserved variable named use to create random results from select nodes + static const FName RandomItemSelectIndexVarName; + +}; + +#if ENGINE_MINOR_VERSION >= 5 +#define SUDS_GET_TEXT_KEY(Text) FTextInspector::GetTextId(Text).GetKey().ToString() +#else +#define SUDS_GET_TEXT_KEY(Text) FTextInspector::GetTextId(Text).GetKey().GetChars() +#endif \ No newline at end of file diff --git a/Plugins/SUDS/Source/SUDS/Public/SUDSDialogue.h b/Plugins/SUDS/Source/SUDS/Public/SUDSDialogue.h new file mode 100644 index 00000000..96c38ba7 --- /dev/null +++ b/Plugins/SUDS/Source/SUDS/Public/SUDSDialogue.h @@ -0,0 +1,694 @@ +// Copyright Steve Streeting 2022 +// Released under the MIT license https://opensource.org/license/MIT/ +#pragma once + +#include "CoreMinimal.h" +#include "SUDSScriptNode.h" +#include "SUDSExpression.h" +#include "UObject/Object.h" +#include "SUDSDialogue.generated.h" + +class USUDSScriptNodeGosub; +class USUDSScriptNodeText; +struct FSUDSScriptEdge; +class USUDSScriptNode; +class USUDSScript; +class UDialogueWave; +class UDialogueVoice; +class USoundBase; + + +DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnDialogueSpeakerLine, class USUDSDialogue*, Dialogue); +DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnDialogueChoice, class USUDSDialogue*, Dialogue, int, ChoiceIndex); +DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnDialogueProceeding, class USUDSDialogue*, Dialogue); +DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnDialogueStarting, class USUDSDialogue*, Dialogue, FName, AtLabel); +DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnDialogueFinished, class USUDSDialogue*, Dialogue); +DECLARE_DYNAMIC_MULTICAST_DELEGATE_ThreeParams(FOnDialogueEvent, class USUDSDialogue*, Dialogue, FName, EventName, const TArray&, Arguments); +DECLARE_DYNAMIC_MULTICAST_DELEGATE_FourParams(FOnVariableChangedEvent, class USUDSDialogue*, Dialogue, FName, VariableName, const FSUDSValue&, Value, bool, bFromScript); +DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnVariableRequestedEvent, class USUDSDialogue*, Dialogue, FName, VariableName); + +#if WITH_EDITOR + // Non-dynamic events for editor use + DECLARE_DELEGATE_TwoParams(FOnDialogueSpeakerLineInternal, class USUDSDialogue* /* Dialogue */, int /*SourceLineNo*/); + DECLARE_DELEGATE_ThreeParams(FOnDialogueChoiceInternal, class USUDSDialogue* /* Dialogue*/, int /*ChoiceIndex*/, int /*SourceLineNo*/); + DECLARE_DELEGATE_OneParam(FOnDialogueProceedingInternal, class USUDSDialogue* /*Dialogue*/); + DECLARE_DELEGATE_TwoParams(FOnDialogueStartingInternal, class USUDSDialogue* /*Dialogue*/, FName /*AtLabel*/); + DECLARE_DELEGATE_OneParam(FOnDialogueFinishedInternal, class USUDSDialogue* /*Dialogue*/); + DECLARE_DELEGATE_FourParams(FOnDialogueEventInternal, class USUDSDialogue* /*Dialogue*/, FName /*EventName*/, const TArray& /*Arguments*/, int /*SourceLineNo*/); + DECLARE_DELEGATE_FiveParams(FOnDialogueVarChangedByScriptInternal, class USUDSDialogue* /* Dialogue*/, FName /*VariableName*/, const FSUDSValue& /*Value*/, const FString& /*ExprString*/, int /*SourceLineNo*/); + DECLARE_DELEGATE_ThreeParams(FOnDialogueVarChangedByCodeInternal, class USUDSDialogue* /* Dialogue*/, FName /*VariableName*/, const FSUDSValue& /*Value*/); + DECLARE_DELEGATE_FourParams(FOnDialogueSelectEval, class USUDSDialogue* /*Dialogue*/, const FString& /*ConditionString*/, bool /*bResult*/, int /*SourceLineNo*/); +#endif + +DECLARE_LOG_CATEGORY_EXTERN(LogSUDSDialogue, Verbose, All); + +/// Copy of the internal state of a dialogue +USTRUCT(BlueprintType) +struct FSUDSDialogueState +{ + GENERATED_BODY() +protected: + UPROPERTY(BlueprintReadOnly, SaveGame, Category="SUDS|Dialogue") + FString TextNodeID; + + UPROPERTY(BlueprintReadOnly, SaveGame, Category="SUDS|Dialogue") + TMap Variables; + + UPROPERTY(BlueprintReadOnly, SaveGame, Category="SUDS|Dialogue") + TArray ChoicesTaken; + + UPROPERTY(BlueprintReadOnly, SaveGame, Category="SUDS|Dialogue") + TArray ReturnStack; + +public: + FSUDSDialogueState() {} + + FSUDSDialogueState(const FString& TxtID, + const TMap& InVars, + const TSet& InChoices, + const TArray& InReturnStack) : TextNodeID(TxtID), + Variables(InVars), + ChoicesTaken(InChoices.Array()), + ReturnStack(InReturnStack) + { + } + + const FString& GetTextNodeID() const { return TextNodeID; } + const TMap& GetVariables() const { return Variables; } + const TArray& GetChoicesTaken() const { return ChoicesTaken; } + const TArray& GetReturnStack() const { return ReturnStack; } + + SUDS_API friend FArchive& operator<<(FArchive& Ar, FSUDSDialogueState& Value); + SUDS_API friend void operator<<(FStructuredArchive::FSlot Slot, FSUDSDialogueState& Value); + bool Serialize(FStructuredArchive::FSlot Slot) + { + Slot << *this; + return true; + } + bool Serialize(FArchive& Ar) + { + Ar << *this; + return true; + } + +}; +/** + * A Dialogue is a runtime instance of a Script (the asset on which the dialogue is based) + * An Dialogue always stops on a speaker line, which may have player choices. It progresses when you call Continue() + * or Choose() and will run that continuation until it hits the next speaker line. In between, other things may occur + * such as setting variables, raising events etc, depending on the script. + * Each dialogue instance has its own state, so you can invoke the same Script multiple times as different dialogues if you want. + * Each dialogue maintains its own internal state, which includes a set of variables. + * Dialogues can have Participants, which are objects closely involved in the dialogue and which have the best access to + * supply and retrieve variables and get events first. Other objects can simply listen to the exposed events; while they + * can manipulate dialogue state too, they have less controllable access in terms of *when* this happens. It's best to + * have at least one Participant driving state on the dialogue (relaying it to external objects), and to have read-only + * users like UIs use the event delegates instead. + * Dialogues need to be owned by an object, mainly for garbage collection. It's recommended that you set the owner to + * one of the NPCs in the dialogue. + * You can save/restore the state of a dialogue via GetSavedState/RestoreSavedState. + */ +UCLASS(BlueprintType) +class SUDS_API USUDSDialogue : public UObject +{ + GENERATED_BODY() +public: + /// Event raised when dialogue progresses and a new speaker line, potentially with new choices, is ready to be displayed + UPROPERTY(BlueprintAssignable) + FOnDialogueSpeakerLine OnSpeakerLine; + /// Event raised when a choice is made in the dialogue by the player. At this point, the dialogue has not progressed + /// as a result of that choice so the index passed can be used to reference the choice + /// This event is ONLY raised if there's a choice of paths, not for just continuing a linear path. + UPROPERTY(BlueprintAssignable) + FOnDialogueChoice OnChoice; + /// Event raised when the dialog is about to proceed away from the current speaker line (because of a choice or continue) + UPROPERTY(BlueprintAssignable) + FOnDialogueProceeding OnProceeding; + /// Event raised when an event is sent from the dialogue script. Any listeners or participants can process the event. + UPROPERTY(BlueprintAssignable) + FOnDialogueEvent OnEvent; + /// Event raised when a variable is changed. "FromScript" is true if the variable was set by the script, false if set from code + UPROPERTY(BlueprintAssignable) + FOnVariableChangedEvent OnVariableChanged; + /// Event raised when a variable is requested by the dialogue script. You can use this hook to set variables in the + /// dialogue on-demand rather than up-front; anything set during this hook will be immediately used by the dialogue + UPROPERTY(BlueprintAssignable) + FOnVariableRequestedEvent OnVariableRequested; + /// Event raised when the dialogue is starting, before the first speaker line + UPROPERTY(BlueprintAssignable) + FOnDialogueStarting OnStarting; + /// Event raised when the dialogue finishes + UPROPERTY(BlueprintAssignable) + FOnDialogueFinished OnFinished; +protected: + UPROPERTY() + TObjectPtr BaseScript; + UPROPERTY() + TObjectPtr CurrentSpeakerNode; + UPROPERTY() + TObjectPtr CurrentRootChoiceNode; + + /// External objects which want to closely participate in the dialogue (not just listen to events) + UPROPERTY() + TArray> Participants; + + + /// All of the dialogue variables + /// Dialogue variable state is all held locally. Dialogue participants can retrieve or set values in state. + /// All state is saved with the dialogue. Variables can be used as text substitution parameters, conditionals, + /// or communication with external state. + typedef TMap FSUDSValueMap; + FSUDSValueMap VariableState; + + /// Stack of Gosub nodes to return to + UPROPERTY() + TArray> GosubReturnStack; + + /// Set of all the TextIDs of choices taken already in this dialogue + TSet ChoicesTaken; + + TSet CurrentRequestedParamNames; + bool bParamNamesExtracted; + + /// Cached derived info + mutable FText CurrentSpeakerDisplayName; + /// All valid choices + TArray CurrentChoices; + int CurrentSourceLineNo; + static const FText DummyText; + static const FString DummyString; + + void InitVariables(); + void RunUntilNextSpeakerNodeOrEnd(USUDSScriptNode* FromNode, bool bRaiseAtEnd); + const USUDSScriptNode* WalkToNextChoiceNode(USUDSScriptNode* FromNode, bool bExecute); + USUDSScriptNode* RecurseWalkToNextChoiceOrTextNode(USUDSScriptNode* Node, bool bExecute, TArray>& LocalGosubStack); + const USUDSScriptNode* RunUntilNextChoiceNode(USUDSScriptNode* FromTextNode); + const USUDSScriptNode* FindNextChoiceNode(USUDSScriptNode* FromNode); + void SetCurrentSpeakerNode(USUDSScriptNodeText* Node, bool bQuietly); + void SortParticipants(); + void RaiseStarting(FName StartLabel); + void RaiseFinished(); + void RaiseNewSpeakerLine(); + void RaiseChoiceMade(int Index, int LineNo); + void RaiseProceeding(); + void RaiseVariableChange(const FName& VarName, const FSUDSValue& Value, bool bFromScript, int LineNo); + void RaiseVariableRequested(const FName& VarName, int LineNo); + void RaiseExpressionVariablesRequested(const FSUDSExpression& Expression, int LineNo); + const TMap& GetGlobalVariables() const; + + USUDSScriptNode* GetNextNode(USUDSScriptNode* Node); + bool IsChoiceOrTextNode(ESUDSScriptNodeType Type); + USUDSScriptNode* RunNode(USUDSScriptNode* Node); + USUDSScriptNode* RunSelectNode(USUDSScriptNode* Node); + USUDSScriptNode* RunSetVariableNode(USUDSScriptNode* Node); + USUDSScriptNode* RunEventNode(USUDSScriptNode* Node); + USUDSScriptNode* RunGosubNode(USUDSScriptNode* Node); + USUDSScriptNode* RunReturnNode(USUDSScriptNode* Node); + void UpdateChoices(); + void RecurseAppendChoices(const USUDSScriptNode* Node, TArray& OutChoices); + USoundBase* GetSoundForCurrentLine(bool bAllowAnyTarget) const; + UDialogueVoice* GetTargetVoice() const; + class USoundConcurrency* GetVoiceSoundConcurrency() const; + + FText ResolveParameterisedText(const TArray Params, const FTextFormat& TextFormat, int LineNo); + void GetTextFormatArgs(const TArray& ArgNames, FFormatNamedArguments& OutArgs) const; + bool CurrentNodeHasChoices() const; + void SetVariableImpl(FName Name, const FSUDSValue& Value, bool bFromScript, int LineNo) + { + const FSUDSValue OldValue = GetVariable(Name); + if (!IsVariableSet(Name) || + (OldValue != Value).GetBooleanValue()) + { + VariableState.Add(Name, Value); + RaiseVariableChange(Name, Value, bFromScript, LineNo); + } + + } + +public: + USUDSDialogue(); + // virtual ~USUDSDialogue() override + // { + // UE_LOG(LogTemp, Warning, TEXT("*********** Destroyed Dialogue!")); + // } + void Initialise(const USUDSScript* Script); + + /// Get the script asset this dialogue is based on + UFUNCTION(BlueprintCallable, BlueprintPure, Category="SUDS|Dialogue") + const USUDSScript* GetScript() const { return BaseScript; } + + /** + * Begin the dialogue. Make sure you've added all participants before calling this. + * This may not be the first time you've started this dialogue. All previous state is maintained to enable you + * for example to take branching paths based on whether you've spoken to this character before. + * If you want to reset *all* state, call Restart(true). However this is an extreme case; if you want to just + * reset some variables then use the header section of the script to set variables to a default starting point. + * @param Label The start point for this dialogue. If None, starts from the beginning. + */ + UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue") + void Start(FName Label = NAME_None); + + + /** + * Add a participant to this dialogue instance. + * Participants are objects which want to be more closely involved in the dialogue. As opposed to event listeners, + * participants get advance notice of events in the dialogue, and are also called in a known order, determined by + * their priority. If you're providing variables to the dialogue, it is best to do it as a participant since it + * gives you much more control. + * @param Participant The participant object, which must implement ISUDSParticipant + */ + UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue") + void AddParticipant(UObject* Participant); + + /// Retrieve participants from this dialogue + UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue") + const TArray& GetParticipants() const { return ObjectPtrDecay(Participants); } + + /** + * Set the complete list of participants for this dialogue instance. + * Participants are objects which want to be more closely involved in the dialogue. As opposed to event listeners, + * participants get advance notice of events in the dialogue, and are also called in a known order, determined by + * their priority. If you're providing variables to the dialogue, it is best to do it as a participant since it + * gives you much more control. + * @param NewParticipants List of new participants. Each should implement ISUDSParticipant + */ + UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue") + void SetParticipants(const TArray& NewParticipants); + + + /// Get the speech text for the current dialogue node + /// Any parameters required will be requested from participants in the dialogue and replaced + UFUNCTION(BlueprintCallable, BlueprintPure, Category="SUDS|Dialogue") + FText GetText(); + + /// Get the DialogueWave associated with the current dialogue node + /// Returns null if there is no wave for this line. + UFUNCTION(BlueprintCallable, BlueprintPure, Category="SUDS|Dialogue") + UDialogueWave* GetWave() const; + + /// Return whether the current dialogue node has a Dialogue Wave associated with it + UFUNCTION(BlueprintCallable, BlueprintPure, Category="SUDS|Dialogue") + bool IsCurrentLineVoiced() const; + + /// Get the ID of the current speaker + UFUNCTION(BlueprintCallable, BlueprintPure, Category="SUDS|Dialogue") + const FString& GetSpeakerID() const; + + /// Get the display name of the current speaker + UFUNCTION(BlueprintCallable, BlueprintPure, Category="SUDS|Dialogue") + FText GetSpeakerDisplayName() const; + + /// Get the Dialogue Voice belonging to the current speaker, if voiced (Null otherwise) + UFUNCTION(BlueprintCallable, BlueprintPure, Category="SUDS|Dialogue") + UDialogueVoice* GetSpeakerVoice() const; + + /// Get the Dialogue Voice belonging to the named participant, if voiced (Null otherwise) + UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue") + UDialogueVoice* GetVoice(FString Name) const; + + /** If the current line is voiced, plays it in 2D. + * @param VolumeMultiplier A linear scalar multiplied with the volume, in order to make the sound louder or softer. + * @param PitchMultiplier A linear scalar multiplied with the pitch. + * @param bLooselyMatchTarget When finding the sound, don't require the target DialogueVoice to match precisely (recommended) + */ + UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue", meta=(AdvancedDisplay = "2", UnsafeDuringActorConstruction = "true", Keywords = "play")) + void PlayVoicedLine2D(float VolumeMultiplier = 1.f, float PitchMultiplier = 1.f, bool bLooselyMatchTarget = true); + + /** If the current line is voiced, plays it at the given location. + * @param Location World position to play dialogue at + * @param Rotation World rotation to play dialogue at + * @param VolumeMultiplier A linear scalar multiplied with the volume, in order to make the sound louder or softer. + * @param PitchMultiplier A linear scalar multiplied with the pitch. + * @param AttenuationSettings Override attenuation settings package to play sound with + * @param bLooselyMatchTarget When finding the sound, don't require the target DialogueVoice to match precisely (recommended) + */ + UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue", meta=(AdvancedDisplay = "4", UnsafeDuringActorConstruction = "true", Keywords = "play")) + void PlayVoicedLineAtLocation(FVector Location, + FRotator Rotation, + float VolumeMultiplier = 1.f, + float PitchMultiplier = 1.f, + USoundAttenuation* AttenuationSettings = nullptr, + bool bLooselyMatchTarget = true); + + /** If the current line is voiced, spawn a sound for it in 2D. Use this if you want to control the sound while it's playing. + * @param VolumeMultiplier A linear scalar multiplied with the volume, in order to make the sound louder or softer. + * @param PitchMultiplier A linear scalar multiplied with the pitch. + * @param bLooselyMatchTarget When finding the sound, don't require the target DialogueVoice to match precisely (recommended) + */ + UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue", meta=(AdvancedDisplay = "2", UnsafeDuringActorConstruction = "true", Keywords = "play")) + UAudioComponent* SpawnVoicedLine2D(float VolumeMultiplier = 1.f, float PitchMultiplier = 1.f, bool bLooselyMatchTarget = true); + + /** If the current line is voiced, spawn a sound for it at the given location. Unlike PlayVoicedLineAtLocation you can + * attach this sound to a moving object if you want + * @param Location World position to play dialogue at + * @param Rotation World rotation to play dialogue at + * @param VolumeMultiplier A linear scalar multiplied with the volume, in order to make the sound louder or softer. + * @param PitchMultiplier A linear scalar multiplied with the pitch. + * @param AttenuationSettings Override attenuation settings package to play sound with + * @param bLooselyMatchTarget When finding the sound, don't require the target DialogueVoice to match precisely (recommended) + */ + UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue", meta=(AdvancedDisplay = "4", UnsafeDuringActorConstruction = "true", Keywords = "play")) + UAudioComponent* SpawnVoicedLineAtLocation(FVector Location, + FRotator Rotation, + float VolumeMultiplier = 1.f, + float PitchMultiplier = 1.f, + USoundAttenuation* AttenuationSettings = nullptr, + bool bLooselyMatchTarget = true); + + /** If the current line is voiced, get the sound which would be played for it. + * @param bLooselyMatchTarget When finding the sound, don't require the target DialogueVoice to match precisely (recommended) + */ + UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue", meta=(AdvancedDisplay = "4", UnsafeDuringActorConstruction = "true", Keywords = "play")) + USoundBase* GetVoicedLineSound(bool bLooselyMatchTarget = true); + + /** + * Get the number of choices available from this node. + * Note, this will return 1 in the case of just linear text progression. The difference between just linked text + * lines and a choice with only 1 option is whether the choice text is blank or not. + * See also IsSimpleContinue() + * @return The number of choices available + */ + UFUNCTION(BlueprintCallable, BlueprintPure, Category="SUDS|Dialogue") + int GetNumberOfChoices() const; + + /** + * Return whether to progress from here is a simple continue (no choices, no text), meaning you probably want + * to display a simpler prompt to the player. + * This will return false even if there's only one choice, if that choice has text associated with it. + */ + UFUNCTION(BlueprintCallable, BlueprintPure, Category="SUDS|Dialogue") + bool IsSimpleContinue() const; + + /** + * Get the text associated with a choice. + * @param Index The index of the choice + * @return The text. This may be blank if this represents just a link between 2 nodes and not a choice at all. + * Note that if you want to have only 1 choice but with associated text, this is fine and should be a choice + * line just like any other. + */ + UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue") + FText GetChoiceText(int Index); + + /// Get all the current choices available, if you prefer this format + UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue") + const TArray& GetChoices() const; + + /** Returns whether the choice at the given index has been taken previously. + * This is saved in dialogue state so will be remembered across save/restore. + */ + UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue") + bool HasChoiceIndexBeenTakenPreviously(int Index); + + /** Returns whether a choice has been taken previously. + * This is saved in dialogue state so will be remembered across save/restore. + */ + UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue") + bool HasChoiceBeenTakenPreviously(const FSUDSScriptEdge& Choice); + + + /** + * Continues the dialogue if (and ONLY if) there is only one valid path/choice out of the current node. + * @return True if the dialogue continues after this, false if the dialogue is now at an end. + */ + UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue") + bool Continue(); + + /** + * Picks one of the available choices + * If there's only 1 you can still call this with Index = 0, but also see Continue + * @param Index The index of the choice to make + * @return True if the dialogue continues, false if it has now reached the end. + */ + UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue") + bool Choose(int Index); + + /// Returns true if the dialogue has reached the end + UFUNCTION(BlueprintCallable, BlueprintPure, Category="SUDS|Dialogue") + bool IsEnded() const; + + /// Returns whether the current speaker line is the last line of dialogue, i.e. there are no + /// further choices and the next continue will end the dialogue. This allows you to anticipate + /// the end of dialogue (IsEnded() will still return false until the last continue is taken) + UFUNCTION(BlueprintCallable, BlueprintPure, Category="SUDS|Dialogue") + bool IsFinalLine() const; + + + /// End the dialogue early + UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue") + void End(bool bQuietly); + + /// Get the source line number of the current position of the dialogue (returns 0 if not applicable) + UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue") + int GetCurrentSourceLine() const; + + + /** + * Restart the dialogue, either from the start or from a named label. + * @param bResetState Whether to reset ALL dialogue state, as if the dialogue had been created anew. You mostly don't want + * to do this; if you have certain things you want to reset every time, then use [set] commands in the header section + * which runs every time the dialogue starts. + * @param StartLabel Label to start running from; if None start from the beginning. + * @param bReRunHeader If true (default), re-runs the header nodes before starting. Header nodes let you initialise + * state that should always be reset when the dialogue is restarted + */ + UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue") + void Restart(bool bResetState = false, FName StartLabel = NAME_None, bool bReRunHeader = true); + + /** + * Reset the state of this dialogue. + * @param bResetVariables If true, resets all variable state + * @param bResetPosition If true, resets the current position in the dialogue (which speaker line is next) + * @param bResetVisited If true, resets the memory of which choices have been made + */ + UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue") + void ResetState(bool bResetVariables = true, bool bResetPosition = true, bool bResetVisited = true); + + /** Retrieve a copy of the state of this dialogue. + * This is useful for saving the state of this dialogue. + * @return A static copy of the current state of this dialogue. This struct can be serialised with your save data, + * and contains both the state of variables and the current speaking node ID. + * @note If you save/load mid-dialogue then you're need to have written Text ID's into the source text to ensure they + * stay the same between edits, as you do for localisation. If you only save/load after dialogue has ended then + * you don't need to worry about this since the dialogue will always start from the beginning + */ + UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue") + FSUDSDialogueState GetSavedState() const; + + /** Restore the saved state of this dialogue. + * This is useful for restoring the state of this dialogue. It will attempt to restore both the value of variables, + * and the current speaking node in the dialogue. If you expect to be able to restore to a point mid-dialogue, + * it's important that Text IDs are defined in your source file (as for localisation) since that's used as the + * identifier of the current speaking node. If you only save/load after dialogue has ended then you don't need + * to worry about this as dialogue will restart each time. + * @param State Dialogue state that you previously retrieved from GetSavedState(). + * @note After restoring, you'll want to either call Start() or Continue(), depending on whether you restored + * mid-dialogue or not (see IsEnded() to tell whether you did) + */ + UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue") + void RestoreSavedState(const FSUDSDialogueState& State); + + /// Get the set of text parameters that are actually being asked for in the current state of the dialogue. + /// This will include parameters in the text, and parameters in any current choices being displayed. + /// Use this if you want to be more specific about what parameters you supply when ISUDSParticipant::UpdateDialogueParameters + /// is called. + UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue") + TSet GetParametersInUse(); + + + /// Set a variable in dialogue state + /// This is mostly only useful if you happen to already have a general purpose FSUDSValue. + /// See SetVariableText, SetVariableInt etc for literal-friendly versions + UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue") + void SetVariable(FName Name, FSUDSValue Value) + { + SetVariableImpl(Name, Value, false, 0); + } + + /// Get a variable in dialogue state as a general value type + /// See GetDialogueText, GetDialogueInt etc for more type friendly versions, but if you want to access the state + /// as a type-flexible value then you can do so with this function. + UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue") + FSUDSValue GetVariable(FName Name) const + { + if (const auto Arg = VariableState.Find(Name)) + { + return *Arg; + } + return FSUDSValue(); + } + + UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue") + bool IsVariableSet(FName Name) const + { + return VariableState.Contains(Name); + } + + /// Get all variables + UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue") + const TMap& GetVariables() const { return VariableState; } + + /** + * Set a text dialogue variable + * @param Name The name of the variable + * @param Value The value of the variable + */ + UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue") + void SetVariableText(FName Name, FText Value) + { + SetVariable(Name, Value); + } + + /** + * Get a text dialogue variable + * @param Name The name of the variable + * @returns Value The value of the variable + */ + UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue") + FText GetVariableText(FName Name) const; + + /** + * Set a dialogue variable on the passed in parameters collection. + * @param Name The name of the variable + * @param Value The value of the variable + */ + UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue") + void SetVariableInt(FName Name, int32 Value); + + /** + * Get an int dialogue variable + * @param Name The name of the variable + * @returns Value The value of the variable + */ + UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue") + int GetVariableInt(FName Name) const; + + /** + * Set a float dialogue variable + * @param Name The name of the variable + * @param Value The value of the variable + */ + UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue") + void SetVariableFloat(FName Name, float Value); + + /** + * Get a float dialogue variable + * @param Name The name of the variable + * @returns Value The value of the variable + */ + UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue") + float GetVariableFloat(FName Name) const; + + /** + * Set a gender dialogue variable + * @param Name The name of the variable + * @param Value The value of the variable + */ + UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue") + void SetVariableGender(FName Name, ETextGender Value); + + /** + * Get a gender dialogue variable + * @param Name The name of the variable + * @returns Value The value of the variable + */ + UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue") + ETextGender GetVariableGender(FName Name) const; + + /** + * Set a boolean dialogue variable + * @param Name The name of the variable + * @param Value The value of the variable + */ + UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue") + void SetVariableBoolean(FName Name, bool Value); + + /** + * Get a boolean dialogue variable + * @param Name The name of the variable + * @returns Value The value of the variable + */ + UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue") + bool GetVariableBoolean(FName Name) const; + + /** + * Set a name dialogue variable + * @param Name The name of the variable + * @param Value The value of the variable + */ + UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue") + void SetVariableName(FName Name, FName Value); + + /** + * Get a name dialogue variable + * @param Name The name of the variable + * @returns Value The value of the variable + */ + UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue") + FName GetVariableName(FName Name) const; + + + /** + * Remove the definition of a variable. + * This has much same effect as setting the variable back to the default value for this type, since attempting to + * retrieve a missing variable result in a default value. + * @param Name The name of the variable + */ + UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue") + void UnSetVariable(FName Name); + + /** + * Get a piece of user-specified metadata for the current speaker line. + * User metadata is assigned by adding a special comment before a speaker line, and can be used + * for anything where you need additional data associated with a particular line (as opposed to + * setting a variable or sending an event). + * @param Key The metadata key + * @return The current metadata value + */ + UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue") + FSUDSValue GetSpeakerLineUserMetadata(FName Key) const; + + /** + * Get all user-specified metadata for the current speaker line. + * User metadata is assigned by adding a special comment before a speaker line, and can be used + * for anything where you need additional data associated with a particular line (as opposed to + * setting a variable or sending an event). + * @return All key/value user metadata for the current speaker line + */ + UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue") + TMap GetAllSpeakerLineUserMetadata() const; + + /** + * Get a piece of user-specified metadata for a choice in the current list of choices. + * User metadata is assigned by adding a special comment before a choice line, and can be used + * for anything where you need additional data associated with a particular choice, such as RPG + * stat requirements. + * @param Index The choice index + * @param Key The metadata key + * @return The current metadata value + */ + UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue") + FSUDSValue GetChoiceUserMetadata(int Index, FName Key) const; + + /** + * Get all user-specified metadata for a choice in the current list of choices. + * User metadata is assigned by adding a special comment before a choice line, and can be used + * for anything where you need additional data associated with a particular choice, such as RPG + * stat requirements. + * @param Index The choice index + * @return All key/value user metadata for the choice + */ + UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue") + TMap GetAllChoiceUserMetadata(int Index) const; + +#if WITH_EDITOR + FOnDialogueSpeakerLineInternal InternalOnSpeakerLine; + FOnDialogueChoiceInternal InternalOnChoice; + FOnDialogueProceedingInternal InternalOnProceeding; + FOnDialogueEventInternal InternalOnEvent; + FOnDialogueVarChangedByScriptInternal InternalOnSetVar; + FOnDialogueVarChangedByCodeInternal InternalOnSetVarByCode; + FOnDialogueSelectEval InternalOnSelectEval; + FOnDialogueStartingInternal InternalOnStarting; + FOnDialogueFinishedInternal InternalOnFinished; +#endif +}; diff --git a/Plugins/SUDS/Source/SUDS/Public/SUDSExpression.h b/Plugins/SUDS/Source/SUDS/Public/SUDSExpression.h new file mode 100644 index 00000000..91258966 --- /dev/null +++ b/Plugins/SUDS/Source/SUDS/Public/SUDSExpression.h @@ -0,0 +1,234 @@ +// Copyright Steve Streeting 2022 +// Released under the MIT license https://opensource.org/license/MIT/ +#pragma once +#include "SUDSValue.h" +#include "SUDSExpression.generated.h" + +UENUM(BlueprintType) +enum class ESUDSExpressionItemType : uint8 +{ + Null = 0 UMETA(Hidden), + // Operators (must be 0-127, in order of precedence, highest first - gaps left in case we need them) + Not = 4, + Multiply = 10, + Divide = 11, + Modulo = 12, + Add = 20, + Subtract = 21, + Less = 30, + LessEqual = 31, + Greater = 32, + GreaterEqual = 33, + Equal = 34, + NotEqual = 35, + And = 40, + Or = 41, + + LParens = 100, + RParens = 101, + + // Operands (must be 128+) + Operand = 128 + +}; + +/// An item in an expression queue, can be operator or operand +USTRUCT(BlueprintType) +struct SUDS_API FSUDSExpressionItem +{ + GENERATED_BODY() + +protected: + UPROPERTY(BlueprintReadOnly, Category="SUDS|Expression") + ESUDSExpressionItemType Type; + + // Value if an operand node + UPROPERTY(BlueprintReadOnly, Category="SUDS|Expression") + FSUDSValue OperandValue; + +public: + + FSUDSExpressionItem() : Type(ESUDSExpressionItemType::Operand) {} + FSUDSExpressionItem(ESUDSExpressionItemType Operator) : Type(Operator) {} + + FSUDSExpressionItem(const FSUDSValue& LiteralOrVariable) + : Type(ESUDSExpressionItemType::Operand), + OperandValue(LiteralOrVariable) + { + } + + ESUDSExpressionItemType GetType() const { return Type; } + // Only valid if optype is operand + const FSUDSValue& GetOperandValue() const { return OperandValue; } + void SetOperandValue(const FSUDSValue& NewVal) { OperandValue = NewVal; } + + bool IsOperator() const { return static_cast(Type) < 128; } + bool IsOperand() const { return !IsOperator(); } + bool IsBinaryOperator() const + { + // Only not is unary right now + return Type != ESUDSExpressionItemType::Not; + } +}; + + +/// An expression holds an executable expression, whether it's a simple single literal +/// or a compound expression with variables +USTRUCT(BlueprintType) +struct SUDS_API FSUDSExpression +{ + GENERATED_BODY() + +protected: + // The output queue in Reverse Polish Notation order + UPROPERTY() + TArray Queue; + + /// Whether the tree is valid to execute + UPROPERTY(BlueprintReadOnly, Category="SUDS|Expression") + bool bIsValid; + + UPROPERTY() + TArray VariableNames; + + + /// The original string version of the expression, for reference + UPROPERTY(BlueprintReadOnly, Category="SUDS|Expression") + FString SourceString; + + FSUDSExpressionItem EvaluateOperator(ESUDSExpressionItemType Op, + const FSUDSExpressionItem& Arg1, + const FSUDSExpressionItem& Arg2, + const TMap& Variables, + const TMap& GlobalVariables) const; + FSUDSValue EvaluateOperand(const FSUDSValue& Operand, const TMap& Variables, const TMap& GlobalVariables) const; + + bool Validate(); + +public: + + FSUDSExpression() : bIsValid(true) {} + + /// Initialise an expression tree just with a single literal or variable + FSUDSExpression(const FSUDSValue& LiteralOrVariable) + { + Queue.Add(FSUDSExpressionItem(LiteralOrVariable)); + if (LiteralOrVariable.IsVariable()) + VariableNames.Add(LiteralOrVariable.GetVariableNameValue()); + bIsValid = true; + } + + /** + * Attempt to parse an expression from a string + * @param Expression The string to parse + * @param OutParseError If there are any errors, pointer to a string to complete with the details + * @return Whether the parsing was successful + */ + bool ParseFromString(const FString& Expression, FString* OutParseError); + + /// Reset the expression to return true + void Reset(); + + + /// Evaluate the expression and return the result, using a given variable state + FSUDSValue Evaluate(const TMap& Variables, const TMap& GlobalVariables) const; + + /// Evaluate the expression and return the result as a boolean, using a given variable state + bool EvaluateBoolean(const TMap& Variables, const TMap& GlobalVariables, const FString& ErrorContext) const; + + /// Get the original source of the expression as a string + const FString& GetSourceString() const { return SourceString; } + + /// Whether this expression can be run (or is empty) + bool IsValid() const { return bIsValid; } + + /// Whether this expression is blank + bool IsEmpty() const { return Queue.IsEmpty(); } + + /// Get the list of variables this expression needs + const TArray& GetVariableNames() const { return VariableNames; } + + /// Return whether this expression is a generated random condition + bool IsRandomCondition() const; + + /** + * Attempt to parse an operand from a string. Returns true if this string is a valid operand, which means a literal + * (int, float, quoted string, boolean, gender), or a variable reference ({VariableName}) + * @param ValueStr The string to parse + * @param OutVal The operand value which will be populated if successful + * @return True if successful, false if not + */ + static bool ParseOperand(const FString& ValueStr, FSUDSValue& OutVal); + + // Attempt to parse an operator from an incoming string + static ESUDSExpressionItemType ParseOperator(const FString& OpStr); + + /// Access the internal RPN execution queue + const TArray& GetQueue() { return Queue; } + + /// Return whether this is a single literal + bool IsLiteral() const + { + return bIsValid && Queue.Num() == 1 && Queue[0].IsOperand() && Queue[0].GetOperandValue().GetType() != ESUDSValueType::Variable; + } + + /// Helper method to get literal values + FSUDSValue GetLiteralValue() const + { + check(IsLiteral()); + return Queue[0].GetOperandValue(); + } + + /// Return whenter this is a text literal + bool IsTextLiteral() const + { + return bIsValid && Queue.Num() == 1 && Queue[0].IsOperand() && Queue[0].GetOperandValue().GetType() == ESUDSValueType::Text; + } + + /// Helper method to get a text literal value, for easier localisation + FText GetTextLiteralValue() const + { + check(IsTextLiteral()); + return GetLiteralValue().GetTextValue(); + } + /// Helper method to override a text literal + void SetTextLiteralValue(const FText& NewLiteral) + { + check(IsTextLiteral()); + Queue[0].SetOperandValue(NewLiteral); + } + + /// Helper method to get boolean literal value + bool GetBooleanLiteralValue() const + { + check(IsLiteral() && GetLiteralValue().GetType() == ESUDSValueType::Boolean); + return GetLiteralValue().GetBooleanValue(); + } + /// Helper method to get int literal value + int GetIntLiteralValue() const + { + check(IsLiteral() && GetLiteralValue().GetType() == ESUDSValueType::Int); + return GetLiteralValue().GetIntValue(); + } + /// Helper method to get float literal value + float GetFloatLiteralValue() const + { + check(IsLiteral() && GetLiteralValue().GetType() == ESUDSValueType::Float); + return GetLiteralValue().GetFloatValue(); + } + /// Helper method to get gender literal value + ETextGender GetGenderLiteralValue() const + { + check(IsLiteral() && GetLiteralValue().GetType() == ESUDSValueType::Gender); + return GetLiteralValue().GetGenderValue(); + } + /// Helper method to get name literal value + FName GetNameLiteralValue() const + { + check(IsLiteral() && GetLiteralValue().GetType() == ESUDSValueType::Name); + return GetLiteralValue().GetNameValue(); + } + + +}; + diff --git a/Plugins/SUDS/Source/SUDS/Public/SUDSLibrary.h b/Plugins/SUDS/Source/SUDS/Public/SUDSLibrary.h new file mode 100644 index 00000000..b1f8aead --- /dev/null +++ b/Plugins/SUDS/Source/SUDS/Public/SUDSLibrary.h @@ -0,0 +1,145 @@ +// Copyright Steve Streeting 2022 +// Released under the MIT license https://opensource.org/license/MIT/ +#pragma once + +#include "CoreMinimal.h" +#include "SUDSValue.h" +#include "Kismet/BlueprintFunctionLibrary.h" +#include "SUDSLibrary.generated.h" + +class USUDSScript; +class USUDSDialogue; +UCLASS() +class SUDS_API USUDSLibrary : public UBlueprintFunctionLibrary +{ + GENERATED_BODY() + +public: + /** + * Create a dialogue instance based on a script, with no participants. + * You should subsequently call "SetParticipants" or "AddParticipant" on the returned dialogue if you expect any + * parameters or speaker names to work. + * @param Owner The owner of this instance. Can be any object but determines the lifespan of this dialogue, + * could make sense to make the owner the NPC you're talking to for example. + * @param Script The script to base this dialogue on + * @param bStartImmediately Whether to call Start() on the dialogue automatically before returning + * @param StartLabel If set to start immediately, which label to start from (None means start from the beginning) + * @return The dialogue instance. + */ + UFUNCTION(BlueprintCallable, Category="SUDS") + static USUDSDialogue* CreateDialogue(UObject* Owner, + USUDSScript* Script, + bool bStartImmediately = false, + FName StartLabel = NAME_None); + + /** + * Create a dialogue instance based on a script, with an initial set of participants. + * @param Owner The owner of this instance. Can be any object but determines the lifespan of this dialogue, + * could make sense to make the owner the NPC you're talking to for example. + * @param Script The script to base this dialogue on + * @param Participants List of participants, each of which must implement the ISUDSParticipant interface to be used. + * Participants are objects that want to be closely involved in the dialogue to provide variables and receive all events. + * Other objects can subscribe to events separately but do not have as much control. + * @param bStartImmediately Whether to call Start() on the dialogue automatically before returning + * @param StartLabel If set to start immediately, which label to start from (None means start from the beginning) + * @return The dialogue instance. + */ + UFUNCTION(BlueprintCallable, Category="SUDS") + static USUDSDialogue* CreateDialogueWithParticipants(UObject* Owner, + USUDSScript* Script, + const TArray& Participants, + bool bStartImmediately = false, + FName StartLabel = NAME_None); + + + /** + * Create a dialogue instance based on a script, with a single participants. + * @param Owner The owner of this instance. Can be any object but determines the lifespan of this dialogue, + * could make sense to make the owner the NPC you're talking to for example. + * @param Script The script to base this dialogue on + * @param Participant The participant, which must implement the ISUDSParticipant interface to be used. + * Participants are objects that want to be closely involved in the dialogue to provide variables and receive all events. + * Other objects can subscribe to events separately but do not have as much control. + * @param bStartImmediately Whether to call Start() on the dialogue automatically before returning + * @param StartLabel If set to start immediately, which label to start from (None means start from the beginning) + * @return The dialogue instance. + */ + UFUNCTION(BlueprintCallable, Category="SUDS") + static USUDSDialogue* CreateDialogueWithParticipant(UObject* Owner, + USUDSScript* Script, + UObject* Participant, + bool bStartImmediately = false, + FName StartLabel = NAME_None); + + /** + * Try to extract a text value from a general SUDS value. + * @param Value The SUDS value, which may contain many types of value + * @param TextValue The text value + * @return True if the value was of type text and extracted correctly. False if not. + */ + UFUNCTION(BlueprintCallable, Category="SUDS") + static UPARAM(DisplayName="Success") bool GetDialogueValueAsText(const FSUDSValue& Value, FText& TextValue); + /** + * Try to extract a boolean value from a general SUDS value. + * @param Value The SUDS value, which may contain many types of value + * @param BoolValue The boolean value + * @return True if the value was of type boolean and extracted correctly. False if not. + */ + UFUNCTION(BlueprintCallable, Category="SUDS") + static UPARAM(DisplayName="Success") bool GetDialogueValueAsBoolean(const FSUDSValue& Value, bool& BoolValue); + /** + * Try to extract an integer value from a general SUDS value. + * @param Value The SUDS value, which may contain many types of value + * @param IntValue The integer value + * @return True if the value was of type integer and extracted correctly. False if not. + */ + UFUNCTION(BlueprintCallable, Category="SUDS") + static UPARAM(DisplayName="Success") bool GetDialogueValueAsInt(const FSUDSValue& Value, int& IntValue); + /** + * Try to extract a float value from a general SUDS value. + * @param Value The SUDS value, which may contain many types of value + * @param FloatValue The float value + * @return True if the value was of type float and extracted correctly. False if not. + */ + UFUNCTION(BlueprintCallable, Category="SUDS") + static UPARAM(DisplayName="Success") bool GetDialogueValueAsFloat(const FSUDSValue& Value, float& FloatValue); + /** + * Try to extract a gender value from a general SUDS value. + * @param Value The SUDS value, which may contain many types of value + * @param GenderValue The gender value + * @return True if the value was of type gender and extracted correctly. False if not. + */ + UFUNCTION(BlueprintCallable, Category="SUDS") + static UPARAM(DisplayName="Success") bool GetDialogueValueAsGender(const FSUDSValue& Value, ETextGender& GenderValue); + /** + * Try to extract a Name value from a general SUDS value. + * @param Value The SUDS value, which may contain many types of value + * @param NameValue The Name value + * @return True if the value was of type Name and extracted correctly. False if not. + */ + UFUNCTION(BlueprintCallable, Category="SUDS") + static UPARAM(DisplayName="Success") bool GetDialogueValueAsName(const FSUDSValue& Value, FName& NameValue); + + /** Retrieve the type of a SUDS value. + * @param Value The SUDS value, which may contain many types of value + * @return The value type + */ + UFUNCTION(BlueprintCallable, Category="SUDS") + static ESUDSValueType GetDialogueValueType(const FSUDSValue& Value); + + /** Determine whether a SUDS value is empty (uninitialised). + * @param Value The SUDS value, which may contain many types of value + * @return Whether the value is empty + */ + UFUNCTION(BlueprintCallable, Category="SUDS") + static bool GetDialogueValueIsEmpty(const FSUDSValue& Value); + + /** + * Determine whether a SUDS variable name refers to a global variable + * @param Name The full name of the variable + * @param OutName The name of the variable, trimmed if necessary to remove a global prefix + * @return Whether the variable was global + */ + UFUNCTION(BlueprintCallable, Category="SUDS") + static bool IsDialogueVariableGlobal(const FName& Name, UPARAM(ref) FName& OutName); +}; diff --git a/Plugins/SUDS/Source/SUDS/Public/SUDSParticipant.h b/Plugins/SUDS/Source/SUDS/Public/SUDSParticipant.h new file mode 100644 index 00000000..91bf6e5e --- /dev/null +++ b/Plugins/SUDS/Source/SUDS/Public/SUDSParticipant.h @@ -0,0 +1,129 @@ +// Copyright Steve Streeting 2022 +// Released under the MIT license https://opensource.org/license/MIT/ +#pragma once + +#include "CoreMinimal.h" +#include "UObject/Interface.h" +#include "SUDSValue.h" +#include "SUDSParticipant.generated.h" + +class USUDSDialogue; +UINTERFACE(MinimalAPI) +class USUDSParticipant : public UInterface +{ + GENERATED_BODY() +}; + +/** +* Interface to be implemented by participant objects in a given dialogue. +* A participant is simply any object which wants to be closely involved in supplying data to, or retrieving data from, +* the dialogue. Although you could do this simply by subscribing to the delegate events on a dialogue, the advantage +* of making a participant is that you have better control over the ordering of multiple participants, in case for example +* there's some common variable that they both want to set. +* It's also a clearer interface to look for vs ad-hoc delegate hooks. +* Generally we recommend that: +* - Objects providing data to the dialogue should implement ISUDSParticipant +* - Anything that just wants to observe the dialogue (like a UI) should just listen to events +* +* Participants are *guaranteed* to be called earlier than delegates, which means you can set variables from the +* Participant callback and when the UI delegate reads text back, all substitution variables will be up to date. +* +*/ +class SUDS_API ISUDSParticipant +{ + GENERATED_BODY() + +public: + + /** + * Called when a dialogue involving this participant is starting. + * The implementation should probably set any starting variables referenced by the dialogue here (or you can do that + * later during other functions). At this point there is no active speaker line, we're bootstrapping. + * @param Dialogue The dialogue + * @param AtLabel The label that the dialogue has started at (None if starting at the beginning) + */ + UFUNCTION(BlueprintCallable, BlueprintNativeEvent, Category="SUDS") + void OnDialogueStarting(USUDSDialogue* Dialogue, FName AtLabel); + + /** + * Called when a dialogue finishes. + * @param Dialogue The dialogue + */ + UFUNCTION(BlueprintCallable, BlueprintNativeEvent, Category="SUDS") + void OnDialogueFinished(USUDSDialogue* Dialogue); + + /** + * Called when a new speaker line, potentially with attached choices, has become active in the dialogue. + * This participant can provide any variable updates if it needs to at this point. + * Participants will be called before any dialogue event listeners. + * @param Dialogue The dialogue + */ + UFUNCTION(BlueprintCallable, BlueprintNativeEvent, Category="SUDS") + void OnDialogueSpeakerLine(USUDSDialogue* Dialogue); + + /** + * Called when a choice is made by the player. + * At this point, the dialogue has not progressed as a result of that choice, so the index passed can be used to + * reference the choice. + * This event is ONLY raised if there's a choice of paths, not for just continuing a linear path. + * See OnDialogueProceeding for a more general callback. + * Participants will be called before any dialogue event listeners. + * @param Dialogue The dialogue + * @param ChoiceIndex The index of the choice that was made + */ + UFUNCTION(BlueprintCallable, BlueprintNativeEvent, Category="SUDS") + void OnDialogueChoiceMade(USUDSDialogue* Dialogue, int ChoiceIndex); + + /** + * Called just before proceeding with the dialogue from the current speaker line; just after either a choice is made by the player + * or the dialogue is just prompted to proceed with its single path. + * Participants will be called before any dialogue event listeners. + * @param Dialogue The dialogue + */ + UFUNCTION(BlueprintCallable, BlueprintNativeEvent, Category="SUDS") + void OnDialogueProceeding(USUDSDialogue* Dialogue); + + + /** + * Called when an event is raised from dialogue + * @param Dialogue The dialogue instance + * @param EventName The name of the event that has been raised + * @param Arguments + */ + UFUNCTION(BlueprintCallable, BlueprintNativeEvent, Category="SUDS") + void OnDialogueEvent(USUDSDialogue* Dialogue, FName EventName, const TArray& Arguments); + + /** + * Called when a variable changes value in the dialogue + * @param Dialogue The dialogue instance + * @param VariableName The name of the variable which has changed value + * @param Value The new value + * @param bFromScript True if the value changed because of a script line, false if it changed because of code calling SetVariable + */ + UFUNCTION(BlueprintCallable, BlueprintNativeEvent, Category="SUDS") + void OnDialogueVariableChanged(USUDSDialogue* Dialogue, FName VariableName, const FSUDSValue& Value, bool bFromScript); + + /** + * Called when a variable value is requested by the dialogue script. + * While you can set variables on the dialogue at any time and they're persistent, you can implement this method to + * provide on-demand variable values (call SetVariable on the dialogue) if you want. This hook is called just before + * the variables are used. + * @param Dialogue The dialogue instance + * @param VariableName The name of the variable which has changed value + */ + UFUNCTION(BlueprintCallable, BlueprintNativeEvent, Category="SUDS") + void OnDialogueVariableRequested(USUDSDialogue* Dialogue, FName VariableName); + + /** + * Return the priority of this participant (default 0). + * If for some reason you need to control the order multiple participants in a dialogue are called, + * override this method; higher priority participants will be called *later* so that their variables etc override + * previously set values. + * @return Relative priority, default 0, higher numbers override lower ones. + */ + UFUNCTION(BlueprintCallable, BlueprintNativeEvent, Category="SUDS") + int GetDialogueParticipantPriority() const; + +}; + + diff --git a/Plugins/SUDS/Source/SUDS/Public/SUDSScript.h b/Plugins/SUDS/Source/SUDS/Public/SUDSScript.h new file mode 100644 index 00000000..f6c3b81e --- /dev/null +++ b/Plugins/SUDS/Source/SUDS/Public/SUDSScript.h @@ -0,0 +1,114 @@ +// Copyright Steve Streeting 2022 +// Released under the MIT license https://opensource.org/license/MIT/ +#pragma once + +#include "CoreMinimal.h" +#include "Runtime/Launch/Resources/Version.h" +#include "Sound/DialogueVoice.h" +#include "UObject/Object.h" +#include "SUDSScript.generated.h" + +class USUDSScriptNode; +class USUDSScriptNodeText; +class USUDSScriptNodeGosub; +/** + * A single SUDS script asset. + */ +UCLASS(BlueprintType) +class SUDS_API USUDSScript : public UObject +{ + GENERATED_BODY() + +protected: + + /// Array of nodes (static after import) + UPROPERTY(BlueprintReadOnly, Category="SUDS") + TArray> Nodes; + + /// Map of labels to nodes + UPROPERTY(BlueprintReadOnly, VisibleDefaultsOnly, Category="SUDS") + TMap LabelList; + + // Header equivalents for startup + UPROPERTY(BlueprintReadOnly, Category="SUDS") + TArray> HeaderNodes; + + UPROPERTY(BlueprintReadOnly, Category="SUDS") + TMap HeaderLabelList; + + /// Array of all speaker IDs found in this script + UPROPERTY(BlueprintReadOnly, VisibleDefaultsOnly, Category="SUDS") + TArray Speakers; + + /// When using VO, Dialogue Voice assets are associated with speaker IDs + UPROPERTY(BlueprintReadOnly, EditDefaultsOnly, Category="SUDS") + TMap> SpeakerVoices; + + bool DoesAnyPathAfterLeadToChoice(USUDSScriptNode* FromNode); + int RecurseLookForChoice(USUDSScriptNode* CurrNode); + +public: + void StartImport(TArray>** Nodes, + TArray>** HeaderNodes, + TMap** LabelList, + TMap** ppHeaderLabelList, + TArray** SpeakerList); + void FinishImport(); + + const TArray& GetNodes() const { return ObjectPtrDecay(Nodes); } + const TArray& GetHeaderNodes() const { return ObjectPtrDecay(HeaderNodes); } + const TMap& GetLabelList() const { return LabelList; } + const TMap& GetHeaderLabelList() const { return HeaderLabelList; } + + + /// Get the first header node, if any (header nodes are run every time the script starts) + UFUNCTION(BlueprintCallable, BlueprintPure, Category="SUDS") + USUDSScriptNode* GetHeaderNode() const; + + /// Get the first node of the script, if starting from the beginning + UFUNCTION(BlueprintCallable, BlueprintPure, Category="SUDS") + USUDSScriptNode* GetFirstNode() const; + + /// Get the next node after a given node, ONLY if there's only one way to go + UFUNCTION(BlueprintCallable, Category="SUDS") + USUDSScriptNode* GetNextNode(const USUDSScriptNode* Node) const; + + /// Get the first node of the script following a label, or null if the label wasn't found + UFUNCTION(BlueprintCallable, Category="SUDS") + USUDSScriptNode* GetNodeByLabel(const FName& Label) const; + + /// Try to find a speaker node by its text ID + UFUNCTION(BlueprintCallable, Category="SUDS") + USUDSScriptNodeText* GetNodeByTextID(const FString& TextID) const; + /// Try to find a gosub node by its gosub ID + UFUNCTION(BlueprintCallable, Category="SUDS") + USUDSScriptNodeGosub* GetNodeByGosubID(const FString& ID) const; + + + /// Get the list of speakers + const TArray& GetSpeakers() const { return Speakers; } + + UFUNCTION(BlueprintCallable, Category="SUDS") + UDialogueVoice* GetSpeakerVoice(const FString& SpeakerID) const; + + /// Set up the speaker voice association + void SetSpeakerVoice(const FString& SpeakerID, UDialogueVoice* Voice); + const TMap& GetSpeakerVoices() const { return ObjectPtrDecay(SpeakerVoices); } + +#if WITH_EDITORONLY_DATA + // Import data for this + UPROPERTY(VisibleAnywhere, Instanced, Category=ImportSettings) + TObjectPtr AssetImportData; + + // UObject interface + virtual void PostInitProperties() override; +#if ENGINE_MAJOR_VERSION == 5 && ENGINE_MINOR_VERSION >= 4 + virtual void GetAssetRegistryTags(FAssetRegistryTagsContext Context) const override; +#else + virtual void GetAssetRegistryTags(TArray& OutTags) const override; +#endif + virtual void Serialize(FArchive& Ar) override; + // End of UObject interface +#endif + +}; diff --git a/Plugins/SUDS/Source/SUDS/Public/SUDSScriptEdge.h b/Plugins/SUDS/Source/SUDS/Public/SUDSScriptEdge.h new file mode 100644 index 00000000..c615b817 --- /dev/null +++ b/Plugins/SUDS/Source/SUDS/Public/SUDSScriptEdge.h @@ -0,0 +1,88 @@ +// Copyright Steve Streeting 2022 +// Released under the MIT license https://opensource.org/license/MIT/ +#pragma once + +#include "CoreMinimal.h" +#include "SUDSExpression.h" +#include "SUDSScriptEdge.generated.h" + +class USUDSScriptNode; + +UENUM(BlueprintType) +enum class ESUDSEdgeType : uint8 +{ + /// A simple continuation; usually for sequences with no choices + Continue, + /// A decision made by the player from a list of choices + Decision, + /// A conditional path, taken automatically based on the situation + Condition, + /// An edge which forms a chain of nodes which are supposed to be considered together + /// This links a text node to its choices, and also potentially compound choices underneath if there are selects + Chained + +}; +/** +* Edge in the script graph. An edge leads to another node (unidirectional) +* Edges can have conditions which mean whether they're valid or not, either as automatic +* choices or player choices. +*/ +USTRUCT(BlueprintType) +struct SUDS_API FSUDSScriptEdge +{ + GENERATED_BODY() +protected: + // Text, if a user choice. Always references a string table + UPROPERTY(BlueprintReadOnly, Category="SUDS") + FText Text; + + UPROPERTY(BlueprintReadOnly, Category="SUDS") + ESUDSEdgeType Type; + + UPROPERTY(BlueprintReadOnly, Category="SUDS") + TWeakObjectPtr TargetNode; + + UPROPERTY(BlueprintReadOnly, Category="SUDS") + FSUDSExpression Condition; + + UPROPERTY(BlueprintReadOnly, Category="SUDS") + int SourceLineNo; + + /// User metadata associated with this edge. May include derived expressions + UPROPERTY() + TMap UserMetadata; + + + mutable bool bFormatExtracted = false; + mutable TArray ParameterNames; + mutable FTextFormat TextFormat; + + void ExtractFormat() const; + +public: + FSUDSScriptEdge(): Type(ESUDSEdgeType::Continue), SourceLineNo(0) + { + } + + FSUDSScriptEdge(USUDSScriptNode* ToNode, ESUDSEdgeType InType, int LineNo); + + FSUDSScriptEdge(const FText& InText, USUDSScriptNode* ToNode, int LineNo); + + FText GetText() const { return Text; } + FString GetTextID() const; + ESUDSEdgeType GetType() const { return Type; } + TWeakObjectPtr GetTargetNode() const { return TargetNode; } + const FSUDSExpression& GetCondition() const { return Condition; } + int GetSourceLineNo() const { return SourceLineNo; } + const TMap& GetUserMetadata() const { return UserMetadata; } + + void SetText(const FText& Text); + void SetType(ESUDSEdgeType InType) { Type = InType; } + void SetTargetNode(const TWeakObjectPtr& InTargetNode); + void SetCondition(const FSUDSExpression& InCondition) { Condition = InCondition; } + void SetUserMetadata(const TMap& Meta) { UserMetadata = Meta; } + + const FTextFormat& GetTextFormat() const; + const TArray& GetParameterNames() const; + bool HasParameters() const; +}; \ No newline at end of file diff --git a/Plugins/SUDS/Source/SUDS/Public/SUDSScriptNode.h b/Plugins/SUDS/Source/SUDS/Public/SUDSScriptNode.h new file mode 100644 index 00000000..d2d5c5a0 --- /dev/null +++ b/Plugins/SUDS/Source/SUDS/Public/SUDSScriptNode.h @@ -0,0 +1,81 @@ +// Copyright Steve Streeting 2022 +// Released under the MIT license https://opensource.org/license/MIT/ +#pragma once + +#include "CoreMinimal.h" +#include "SUDSScriptEdge.h" +#include "UObject/Object.h" +#include "SUDSScriptNode.generated.h" + +UENUM(BlueprintType) +enum class ESUDSScriptNodeType : uint8 +{ + /// Text node, displaying a line of dialogue + Text, + /// Choice node, displaying a series of user choices which navigate to other nodes + Choice, + /// Select node, automatically selecting one which navigates to another node based on state + Select, + /// Set variable node + SetVariable, + /// Event node + Event, + /// Gosub node + Gosub, + /// Return node + Return, +}; +/** + * A node in the script graph. + * Nodes are either text, or branch points (user choice or automatic branching logic) + * Text nodes always lead to a single next step, be that another text node or a branch. + * Branch nodes are separate from the text so that jumping can return to a choice point without emitting more text. + * At runtime if a text node's next step is a user choice, it will be available immediately. Otherwise it's not + * evaluated until the dialogue progresses. + * Edges connect everything. Edges may have text if they're user choices (even if that's a single choice), and + * may have conditions. + */ +UCLASS(BlueprintType) +class SUDS_API USUDSScriptNode : public UObject +{ + GENERATED_BODY() + +protected: + + /// Type of node + /// To make it easier to check rather than having to cast to subtypes blindly. And also not all types need a subtype + UPROPERTY(BlueprintReadOnly, Category="SUDS") + ESUDSScriptNodeType NodeType = ESUDSScriptNodeType::Text; + /// Links to other nodes + UPROPERTY(BlueprintReadOnly, Category="SUDS") + TArray Edges; + /// The line number in the script that this node came from + UPROPERTY(BlueprintReadOnly, Category="SUDS") + int SourceLineNo; + + +public: + USUDSScriptNode(); + + ESUDSScriptNodeType GetNodeType() const { return NodeType; } + const TArray& GetEdges() const { return Edges; } + int GetSourceLineNo() const { return SourceLineNo; } + + void AddEdge(const FSUDSScriptEdge& NewEdge); + void InitChoice(int LineNo); + void InitSelect(int LineNo); + void InitReturn(int LineNo); + + int GetEdgeCount() const { return Edges.Num(); } + const FSUDSScriptEdge* GetEdge(int Index) const + { + if (Edges.IsValidIndex(Index)) + { + return &Edges[Index]; + } + return nullptr; + } + + /// Determine if this node is a Select node that's representing a [random] + bool IsRandomSelect() const; +}; diff --git a/Plugins/SUDS/Source/SUDS/Public/SUDSScriptNodeEvent.h b/Plugins/SUDS/Source/SUDS/Public/SUDSScriptNodeEvent.h new file mode 100644 index 00000000..ab5a242a --- /dev/null +++ b/Plugins/SUDS/Source/SUDS/Public/SUDSScriptNodeEvent.h @@ -0,0 +1,33 @@ +// Copyright Steve Streeting 2022 +// Released under the MIT license https://opensource.org/license/MIT/ +#pragma once + +#include "CoreMinimal.h" +#include "SUDSExpression.h" +#include "SUDSScriptNode.h" +#include "SUDSScriptNodeEvent.generated.h" + +/** + * + */ +UCLASS() +class SUDS_API USUDSScriptNodeEvent : public USUDSScriptNode +{ + GENERATED_BODY() +protected: + // Variable identifier + UPROPERTY(BlueprintReadOnly, Category="SUDS") + FName EventName; + + /// Literal arguments + UPROPERTY(BlueprintReadOnly, Category="SUDS") + TArray Args; + +public: + + void Init(const FString& EvtName, const TArray& InArgs, int LineNo); + FName GetEventName() const { return EventName; } + const TArray& GetArgs() const { return Args; } + + +}; diff --git a/Plugins/SUDS/Source/SUDS/Public/SUDSScriptNodeGosub.h b/Plugins/SUDS/Source/SUDS/Public/SUDSScriptNodeGosub.h new file mode 100644 index 00000000..c0b09c51 --- /dev/null +++ b/Plugins/SUDS/Source/SUDS/Public/SUDSScriptNodeGosub.h @@ -0,0 +1,49 @@ +// Copyright Steve Streeting 2022 +// Released under the MIT license https://opensource.org/license/MIT/ +#pragma once + +#include "CoreMinimal.h" +#include "SUDSScriptNode.h" +#include "SUDSScriptNodeGosub.generated.h" + +/** + * + */ +UCLASS() +class SUDS_API USUDSScriptNodeGosub : public USUDSScriptNode +{ + GENERATED_BODY() +protected: + /// Name of the label which we'll jump to before returning + UPROPERTY(BlueprintReadOnly, Category="SUDS") + FName LabelName; + + /// Generated ID for use when saving state + UPROPERTY(BlueprintReadOnly, Category="SUDS") + FString GosubID; + + /// Convenience flag to let you know whether this node MAY HAVE any choices directly after it + /// Internally this also lets us know to look for the next choice node after returning + /// It's possible that where there are conditionals ahead, there are only choices on some of the paths. + /// This flag is to let us know to look for choices, but if conditionals apply we may not find any using actual dialogue state. + UPROPERTY(BlueprintReadOnly, Category="SUDS") + bool bHasChoices = false; + +public: + + void Init(const FString& Label, const FString ID, int LineNo) + { + NodeType = ESUDSScriptNodeType::Gosub; + LabelName = FName(Label); + GosubID = ID; + SourceLineNo = LineNo; + } + FName GetLabelName() const { return LabelName; } + const FString& GetGosubID() const { return GosubID; } + /// Whether on one select path or another a choice was found + /// Doesn't help if within a Gosub as call site may be anywhere + bool MayHaveChoices() const { return bHasChoices; } + + void NotifyMayHaveChoices() { bHasChoices = true; } + +}; diff --git a/Plugins/SUDS/Source/SUDS/Public/SUDSScriptNodeSet.h b/Plugins/SUDS/Source/SUDS/Public/SUDSScriptNodeSet.h new file mode 100644 index 00000000..f7bb92e5 --- /dev/null +++ b/Plugins/SUDS/Source/SUDS/Public/SUDSScriptNodeSet.h @@ -0,0 +1,33 @@ +// Copyright Steve Streeting 2022 +// Released under the MIT license https://opensource.org/license/MIT/ +#pragma once + +#include "CoreMinimal.h" +#include "SUDSExpression.h" +#include "SUDSScriptNode.h" +#include "SUDSScriptNodeSet.generated.h" + +/** +* Set variable node +*/ +UCLASS() +class SUDS_API USUDSScriptNodeSet : public USUDSScriptNode +{ + GENERATED_BODY() + +protected: + // Variable identifier + UPROPERTY(BlueprintReadOnly, Category="SUDS") + FName Identifier; + + /// Expression to provide value to set + UPROPERTY(BlueprintReadOnly, Category="SUDS") + FSUDSExpression Expression; + +public: + + void Init(const FString& VarName, const FSUDSExpression& InExpression, int LineNo); + const FName& GetIdentifier() const { return Identifier; } + const FSUDSExpression& GetExpression() const { return Expression; } + +}; diff --git a/Plugins/SUDS/Source/SUDS/Public/SUDSScriptNodeText.h b/Plugins/SUDS/Source/SUDS/Public/SUDSScriptNodeText.h new file mode 100644 index 00000000..d94306f9 --- /dev/null +++ b/Plugins/SUDS/Source/SUDS/Public/SUDSScriptNodeText.h @@ -0,0 +1,72 @@ +// Copyright Steve Streeting 2022 +// Released under the MIT license https://opensource.org/license/MIT/ +#pragma once + +#include "CoreMinimal.h" +#include "SUDSScriptNode.h" +#include "SUDSScriptNodeText.generated.h" + +class UDialogueWave; + +/** +* A node which contains speaker text +*/ +UCLASS(BlueprintType) +class SUDS_API USUDSScriptNodeText : public USUDSScriptNode +{ + GENERATED_BODY() + +protected: + /// Identifier of the speaker for text nodes + UPROPERTY(BlueprintReadOnly, VisibleDefaultsOnly, Category="SUDS") + FString SpeakerID; + /// Text, always references a string table. Parameters will not have been completed. + /// Note: if you're using voiced dialogue, see the Wave property and its subtitle functionality + UPROPERTY(BlueprintReadOnly, VisibleDefaultsOnly, Category="SUDS") + FText Text; + /// DialogueWave asset link for voiced dialogue + UPROPERTY(BlueprintReadOnly, EditDefaultsOnly, Category="SUDS") + TObjectPtr Wave; + + /// Convenience flag to let you know whether this text node MAY HAVE choices attached + /// If false, there's only one way to proceed from here and no text associated with that + /// If true, either there can be > 1 choice options, or a single choice with associated text (this can be when + /// you have no choice but want text rather than just a continue button) + /// Internally this also lets us know to look for the next choice node + /// It's possible that where there are conditionals ahead, there are only choices on some of the paths. + /// This flag is to let us know to look for choices, but if conditionals apply we may not find any using actual dialogue state. + UPROPERTY(BlueprintReadOnly, Category="SUDS") + bool bHasChoices = false; + + /// User metadata associated with this speaker line. May include derived expressions + UPROPERTY() + TMap UserMetadata; + + mutable bool bFormatExtracted = false; + mutable TArray ParameterNames; + mutable FTextFormat TextFormat; + + void ExtractFormat() const; + +public: + const FString& GetSpeakerID() const { return SpeakerID; } + const FText& GetText() const { return Text; } + FString GetTextID() const; + UDialogueWave* GetWave() const { return Wave; } + /// Whether on one select path or another a choice was found + /// Doesn't help if within a Gosub as call site may be anywhere + bool MayHaveChoices() const { return bHasChoices; } + + void Init(const FString& SpeakerID, const FText& Text, int LineNo); + void SetWave(UDialogueWave* InWave) { Wave = InWave; } + const FTextFormat& GetTextFormat() const; + const TArray& GetParameterNames() const; + bool HasParameters() const; + + void NotifyMayHaveChoices() { bHasChoices = true; } + + const TMap& GetUserMetadata() const { return UserMetadata; } + void SetUserMetadata(const TMap& Meta) { UserMetadata = Meta; } + + +}; diff --git a/Plugins/SUDS/Source/SUDS/Public/SUDSSubsystem.h b/Plugins/SUDS/Source/SUDS/Public/SUDSSubsystem.h new file mode 100644 index 00000000..d3bbef6c --- /dev/null +++ b/Plugins/SUDS/Source/SUDS/Public/SUDSSubsystem.h @@ -0,0 +1,292 @@ +// Copyright Steve Streeting 2022 +// Released under the MIT license https://opensource.org/license/MIT/ +#pragma once + +#include "CoreMinimal.h" +#include "SUDSValue.h" +#include "Subsystems/GameInstanceSubsystem.h" +#include "Engine/World.h" +#include "Engine/GameInstance.h" +#include "SUDSSubsystem.generated.h" + +class USUDSDialogue; +class USUDSScript; +class USoundConcurrency; +struct FSoundConcurrencySettings; +DECLARE_LOG_CATEGORY_EXTERN(LogSUDSSubsystem, Log, All); + +DECLARE_DYNAMIC_MULTICAST_DELEGATE_ThreeParams(FOnGlobalVariableChangedEvent, FName, VariableName, const FSUDSValue&, Value, bool, bFromScript); + +/// Copy of the global state of the system +USTRUCT(BlueprintType) +struct FSUDSGlobalState +{ + GENERATED_BODY() +protected: + UPROPERTY(BlueprintReadOnly, SaveGame, Category="SUDS") + TMap GlobalVariables; + +public: + FSUDSGlobalState() {} + + FSUDSGlobalState(const TMap& InGlobalVars) : GlobalVariables(InGlobalVars) + { + } + + const TMap& GetGlobalVariables() const { return GlobalVariables; } + + SUDS_API friend FArchive& operator<<(FArchive& Ar, FSUDSGlobalState& Value); + SUDS_API friend void operator<<(FStructuredArchive::FSlot Slot, FSUDSGlobalState& Value); + bool Serialize(FStructuredArchive::FSlot Slot) + { + Slot << *this; + return true; + } + bool Serialize(FArchive& Ar) + { + Ar << *this; + return true; + } + +}; + +/** + * + */ +UCLASS() +class SUDS_API USUDSSubsystem : public UGameInstanceSubsystem +{ + GENERATED_BODY() + +public: + virtual void Initialize(FSubsystemCollectionBase& Collection) override; + virtual void Deinitialize() override; + + /// Event raised when a global variable is changed. "FromScript" is true if the variable was set by the script, false if set from code + UPROPERTY(BlueprintAssignable) + FOnGlobalVariableChangedEvent OnGlobalVariableChanged; + +protected: + UPROPERTY() + TObjectPtr VoiceConcurrency; + + /// Global variable state + TMap GlobalVariableState; + + void SetGlobalVariableImpl(FName Name, const FSUDSValue& Value, bool bFromScript, int LineNo) + { + const FSUDSValue OldValue = GetGlobalVariable(Name); + if (!IsGlobalVariableSet(Name) || + (OldValue != Value).GetBooleanValue()) + { + GlobalVariableState.Add(Name, Value); + OnGlobalVariableChanged.Broadcast(Name, Value, bFromScript); + } + } + +public: + /** + * Sets the number of voiced lines that can be played at once. Defaults to 1, so that when a new voiced line is + * played while another is still playing, the previous one is stopped. If you would prefer that they overlap, set + * this to >1 + * @param ConcurrentLines The number of voice lines that can be played at once. + */ + UFUNCTION(BlueprintCallable, Category="SUDS|Settings") + void SetMaxConcurrentVoicedLines(int ConcurrentLines); + + /** + * Gets the number of voiced lines that can be played at once. Defaults to 1, so that when a new voiced line is + * played while another is still playing, the previous one is stopped. + */ + UFUNCTION(BlueprintCallable, Category="SUDS|Settings") + int GetMaxConcurrentVoicedLines() const; + + USoundConcurrency* GetVoicedLineConcurrency() const { return VoiceConcurrency; } + + + /** + * Reset the global state of the system. + * @param bResetVariables If true, resets all variable state + */ + UFUNCTION(BlueprintCallable, Category="SUDS|Global State") + void ResetGlobalState(bool bResetVariables = true); + + /** Retrieve a copy of the global state of the system. + * This is useful for saving the state of e.g. global variables. + * @return A static copy of the current global state. This struct can be serialised with your save data, + * and contains the state of global variables. + */ + UFUNCTION(BlueprintCallable, Category="SUDS|Global State") + FSUDSGlobalState GetSavedGlobalState() const; + + /** Restore the global saved state. + * This is useful for restoring global state eg global variables when you're loading a game. + * @param State Global state that you previously retrieved from GetSavedGlobalState(). + */ + UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue") + void RestoreSavedGlobalState(const FSUDSGlobalState& State); + + /// Set a global variable + /// This is mostly only useful if you happen to already have a general purpose FSUDSValue. + /// See SetGlobalVariableText, SetGlobalVariableInt etc for literal-friendly versions + UFUNCTION(BlueprintCallable, Category="SUDS|Global Variables") + void SetGlobalVariable(FName Name, FSUDSValue Value) + { + SetGlobalVariableImpl(Name, Value, false, 0); + } + + /// Internal use only + void InternalSetGlobalVariable(FName Name, const FSUDSValue& Value, bool bFromScript, int LineNo) { SetGlobalVariableImpl(Name, Value, bFromScript, LineNo); } + + /// Get a variable in dialogue state as a general value type + /// See GetDialogueText, GetDialogueInt etc for more type friendly versions, but if you want to access the state + /// as a type-flexible value then you can do so with this function. + UFUNCTION(BlueprintCallable, Category="SUDS|Global Variables") + FSUDSValue GetGlobalVariable(FName Name) const + { + if (const auto Arg = GlobalVariableState.Find(Name)) + { + return *Arg; + } + return FSUDSValue(); + } + + UFUNCTION(BlueprintCallable, Category="SUDS|Global Variables") + bool IsGlobalVariableSet(FName Name) const + { + return GlobalVariableState.Contains(Name); + } + + /// Get all variables + UFUNCTION(BlueprintCallable, Category="SUDS|Global Variables") + const TMap& GetGlobalVariables() const { return GlobalVariableState; } + + /** + * Set a text global variable + * @param Name The name of the variable + * @param Value The value of the variable + */ + UFUNCTION(BlueprintCallable, Category="SUDS|Global Variables") + void SetGlobalVariableText(FName Name, FText Value) + { + SetGlobalVariable(Name, Value); + } + + /** + * Get a text global variable + * @param Name The name of the variable + * @returns Value The value of the variable + */ + UFUNCTION(BlueprintCallable, Category="SUDS|Global Variables") + FText GetGlobalVariableText(FName Name) const; + + /** + * Set a global variable on the passed in parameters collection. + * @param Name The name of the variable + * @param Value The value of the variable + */ + UFUNCTION(BlueprintCallable, Category="SUDS|Global Variables") + void SetGlobalVariableInt(FName Name, int32 Value); + + /** + * Get an int global variable + * @param Name The name of the variable + * @returns Value The value of the variable + */ + UFUNCTION(BlueprintCallable, Category="SUDS|Global Variables") + int GetGlobalVariableInt(FName Name) const; + + /** + * Set a float global variable + * @param Name The name of the variable + * @param Value The value of the variable + */ + UFUNCTION(BlueprintCallable, Category="SUDS|Global Variables") + void SetGlobalVariableFloat(FName Name, float Value); + + /** + * Get a float global variable + * @param Name The name of the variable + * @returns Value The value of the variable + */ + UFUNCTION(BlueprintCallable, Category="SUDS|Global Variables") + float GetGlobalVariableFloat(FName Name) const; + + /** + * Set a gender global variable + * @param Name The name of the variable + * @param Value The value of the variable + */ + UFUNCTION(BlueprintCallable, Category="SUDS|Global Variables") + void SetGlobalVariableGender(FName Name, ETextGender Value); + + /** + * Get a gender global variable + * @param Name The name of the variable + * @returns Value The value of the variable + */ + UFUNCTION(BlueprintCallable, Category="SUDS|Global Variables") + ETextGender GetGlobalVariableGender(FName Name) const; + + /** + * Set a boolean global variable + * @param Name The name of the variable + * @param Value The value of the variable + */ + UFUNCTION(BlueprintCallable, Category="SUDS|Global Variables") + void SetGlobalVariableBoolean(FName Name, bool Value); + + /** + * Get a boolean global variable + * @param Name The name of the variable + * @returns Value The value of the variable + */ + UFUNCTION(BlueprintCallable, Category="SUDS|Global Variables") + bool GetGlobalVariableBoolean(FName Name) const; + + /** + * Set a name global variable + * @param Name The name of the variable + * @param Value The value of the variable + */ + UFUNCTION(BlueprintCallable, Category="SUDS|Global Variables") + void SetGlobalVariableName(FName Name, FName Value); + + /** + * Get a name global variable + * @param Name The name of the variable + * @returns Value The value of the variable + */ + UFUNCTION(BlueprintCallable, Category="SUDS|Global Variables") + FName GetGlobalVariableName(FName Name) const; + + + /** + * Remove the definition of a global variable. + * This has much same effect as setting the variable back to the default value for this type, since attempting to + * retrieve a missing variable result in a default value. + * @param Name The name of the variable + */ + UFUNCTION(BlueprintCallable, Category="SUDS|Global Variables") + void UnSetGlobalVariable(FName Name); + +#if WITH_EDITORONLY_DATA + /// Only for use by tests / editor tools when real subsystem isn't running + static TMap Test_DummyGlobalVariables; +#endif + + + +}; + +inline USUDSSubsystem* GetSUDSSubsystem(UWorld* WorldContext) +{ + if (IsValid(WorldContext) && WorldContext->IsGameWorld()) + { + auto GI = WorldContext->GetGameInstance(); + if (IsValid(GI)) + return GI->GetSubsystem(); + } + + return nullptr; +} diff --git a/Plugins/SUDS/Source/SUDS/Public/SUDSValue.h b/Plugins/SUDS/Source/SUDS/Public/SUDSValue.h new file mode 100644 index 00000000..05f0a0ac --- /dev/null +++ b/Plugins/SUDS/Source/SUDS/Public/SUDSValue.h @@ -0,0 +1,416 @@ +// Copyright Steve Streeting 2022 +// Released under the MIT license https://opensource.org/license/MIT/ +#pragma once + +#include "SUDSCommon.h" +#include "SUDSValue.generated.h" + + +UENUM(BlueprintType) +enum class ESUDSValueType : uint8 +{ + Text = 0, + Int = 1, + Float = 2, + Boolean = 3, + Gender = 4, + Name = 5, + /// Access the value of another variable + Variable = 10, + + Empty = 99 +}; +/// Struct which can hold any of the value types that SUDS needs to use, in a Blueprint friendly manner +/// For getting / setting these values from blueprints, see blueprint library functions SetSUDSValue() / GetSUDSValue() +/// For convenience these are wrapped in USUDSDialogue but in e.g. event callbacks they're not +USTRUCT(BlueprintType) +struct SUDS_API FSUDSValue +{ + GENERATED_BODY() +protected: + ESUDSValueType Type; + union + { + int32 IntValue; + float FloatValue; + }; + TOptional TextValue; + // Used for variables and name values + TOptional Name; +public: + + FSUDSValue() : Type(ESUDSValueType::Empty), IntValue(0), TextValue(FText::GetEmpty()) {} + + FSUDSValue(const int32 Value) + : Type(ESUDSValueType::Int) { IntValue = Value; } + + FSUDSValue(const float Value) + : Type(ESUDSValueType::Float) { FloatValue = Value; } + + FSUDSValue(const FText& Value) + : Type(ESUDSValueType::Text), + IntValue(0), + TextValue(Value) + { + } + + FSUDSValue(FText&& Value) + : Type(ESUDSValueType::Text), IntValue(0), TextValue(MoveTemp(Value)) + { + } + + FSUDSValue(ETextGender Value) + : Type(ESUDSValueType::Gender), IntValue(0) + { + IntValue = static_cast(Value); + } + + FSUDSValue(bool Value) + : Type(ESUDSValueType::Boolean), IntValue(0) + { + IntValue = Value ? 1 : 0; + } + + FSUDSValue(const FName& ReferencedName, bool bIsVariable) + : Type(bIsVariable ? ESUDSValueType::Variable : ESUDSValueType::Name), + IntValue(0), + Name(ReferencedName) + { + } + + // Construct a default value of a given type + explicit FSUDSValue(ESUDSValueType ValType) + : Type(ValType), IntValue(0) + { + } + + /// Whether this value is empty, i.e. hasn't been set to anything + FORCEINLINE bool IsEmpty() const + { + return Type == ESUDSValueType::Empty; + } + + FORCEINLINE ESUDSValueType GetType() const + { + return Type; + } + + FORCEINLINE int32 GetIntValue() const + { + // We don't warn for unset variables / uninitialised values, use the defaults + if (!IsEmpty() && Type != ESUDSValueType::Int && Type != ESUDSValueType::Variable) + UE_LOG(LogSUDS, Warning, TEXT("Getting value as int but was type %s"), *StaticEnum()->GetValueAsString(Type)) + + return IntValue; + } + + FORCEINLINE float GetFloatValue() const + { + // We don't warn for unset variables, use the defaults + if (!IsEmpty() && !IsNumeric() && Type != ESUDSValueType::Variable) + UE_LOG(LogSUDS, Warning, TEXT("Getting value as float but was type %s"), *StaticEnum()->GetValueAsString(Type)) + + if (Type == ESUDSValueType::Int) + { + // Allow int widening to float + return GetIntValue(); + } + return FloatValue; + } + + FORCEINLINE const FText& GetTextValue() const + { + // We don't warn for unset variables, use the defaults + if (!IsEmpty() && Type != ESUDSValueType::Text && Type != ESUDSValueType::Variable) + UE_LOG(LogSUDS, Warning, TEXT("Getting value as text but was type %s"), *StaticEnum()->GetValueAsString(Type)) + + if (TextValue.IsSet()) + return TextValue.GetValue(); + + return FText::GetEmpty(); + } + + FORCEINLINE ETextGender GetGenderValue() const + { + // We don't warn for unset variables, use the defaults + if (!IsEmpty() && Type != ESUDSValueType::Gender && Type != ESUDSValueType::Variable) + UE_LOG(LogSUDS, Warning, TEXT("Getting value as float but was type %s"), *StaticEnum()->GetValueAsString(Type)) + + return static_cast(IntValue); + } + + FORCEINLINE bool GetBooleanValue() const + { + // We don't warn for unset variables, use the defaults + if (!IsEmpty() && Type != ESUDSValueType::Boolean && Type != ESUDSValueType::Variable) + UE_LOG(LogSUDS, Warning, TEXT("Getting value as boolean but was type %s"), *StaticEnum()->GetValueAsString(Type)) + + return IntValue != 0; + } + + FORCEINLINE FName GetNameValue() const + { + // We don't warn for unset variables, use the defaults + if (!IsEmpty() && Type != ESUDSValueType::Name && Type != ESUDSValueType::Variable) + UE_LOG(LogSUDS, Warning, TEXT("Getting value as Name but was type %s"), *StaticEnum()->GetValueAsString(Type)) + + if (Name.IsSet()) + return Name.GetValue(); + + return NAME_None; + } + + FORCEINLINE FName GetVariableNameValue() const + { + if (!IsEmpty() && Type != ESUDSValueType::Variable) + UE_LOG(LogSUDS, Warning, TEXT("Getting value as variable name but was type %s"), *StaticEnum()->GetValueAsString(Type)) + + if (Name.IsSet()) + return Name.GetValue(); + + return NAME_None; + } + + FORCEINLINE bool IsVariable() const + { + return Type == ESUDSValueType::Variable; + } + + bool IsNumeric() const + { + return Type == ESUDSValueType::Float || Type == ESUDSValueType::Int; + } + + FFormatArgumentValue ToFormatArg() const + { + switch (Type) + { + default: + case ESUDSValueType::Text: + return FFormatArgumentValue(TextValue.GetValue()); + case ESUDSValueType::Int: + return FFormatArgumentValue(GetIntValue()); + case ESUDSValueType::Boolean: + return FFormatArgumentValue(GetBooleanValue()); + case ESUDSValueType::Gender: + return FFormatArgumentValue(GetGenderValue()); + case ESUDSValueType::Float: + return FFormatArgumentValue(GetFloatValue()); + case ESUDSValueType::Name: + // return something useful from FName even though technically shouldn't use in visible text + return FFormatArgumentValue(FText::FromName(GetNameValue())); + case ESUDSValueType::Empty: + case ESUDSValueType::Variable: + return FFormatArgumentValue(); + } + } + + void SetValue(const FSUDSValue& RValue) + { + *this = RValue; + } + + /// Not operation, technically only valid on booleans, but not enforced so as to allow unset vars & conversions + FSUDSValue operator!() const + { + return FSUDSValue(!GetBooleanValue()); + } + + FSUDSValue operator*(const FSUDSValue& Rhs) const + { + // We don't force types to be numeric here so that we can safely use unset variables + // If both int, keep int + if (Type == ESUDSValueType::Int && Rhs.Type == Type) + { + return FSUDSValue(GetIntValue() * Rhs.GetIntValue()); + } + // Otherwise we'll let the type widening handle mixed types for us (ternary will always resolve to float) + return FSUDSValue( + (Type == ESUDSValueType::Float ? GetFloatValue() : GetIntValue()) + * + (Rhs.GetType() == ESUDSValueType::Float ? Rhs.GetFloatValue() : Rhs.GetIntValue())); + } + FSUDSValue operator/(const FSUDSValue& Rhs) const + { + // We don't force types to be numeric here so that we can safely use unset variables + // If both int, keep int + if (Type == ESUDSValueType::Int && Rhs.Type == Type) + { + return FSUDSValue(GetIntValue() / Rhs.GetIntValue()); + } + // Otherwise we'll let the type widening handle mixed types for us (ternary will always resolve to float) + return FSUDSValue( + (Type == ESUDSValueType::Float ? GetFloatValue() : GetIntValue()) + / + (Rhs.GetType() == ESUDSValueType::Float ? Rhs.GetFloatValue() : Rhs.GetIntValue())); + } + FSUDSValue operator%(const FSUDSValue& Rhs) const + { + // We don't force types to be numeric here so that we can safely use unset variables + // If both int, keep int + if (Type == ESUDSValueType::Int && Rhs.Type == Type) + { + return FSUDSValue(GetIntValue() % Rhs.GetIntValue()); + } + // Otherwise we'll let the type widening handle mixed types for us (ternary will always resolve to float) + float lval = (Type == ESUDSValueType::Float ? GetFloatValue() : GetIntValue()); + float rval = (Rhs.GetType() == ESUDSValueType::Float ? Rhs.GetFloatValue() : Rhs.GetIntValue()); + // Prevent NaN and errors reported when executing FMath::Fmod with invalid values. + return FSUDSValue(rval != 0 ? FMath::Fmod(lval, rval) : 0.0f); + } + FSUDSValue operator+(const FSUDSValue& Rhs) const + { + // We don't force types to be numeric here so that we can safely use unset variables + // If both int, keep int + if (Type == ESUDSValueType::Int && Rhs.Type == Type) + { + return FSUDSValue(GetIntValue() + Rhs.GetIntValue()); + } + // Otherwise we'll let the type widening handle mixed types for us (ternary will always resolve to float) + return FSUDSValue( + (Type == ESUDSValueType::Float ? GetFloatValue() : GetIntValue()) + + + (Rhs.GetType() == ESUDSValueType::Float ? Rhs.GetFloatValue() : Rhs.GetIntValue())); + } + FSUDSValue operator-(const FSUDSValue& Rhs) const + { + // We don't force types to be numeric here so that we can safely use unset variables + // If both int, keep int + if (Type == ESUDSValueType::Int && Rhs.Type == Type) + { + return FSUDSValue(GetIntValue() - Rhs.GetIntValue()); + } + // Otherwise we'll let the type widening handle mixed types for us (ternary will always resolve to float) + return FSUDSValue( + (Type == ESUDSValueType::Float ? GetFloatValue() : GetIntValue()) + - + (Rhs.GetType() == ESUDSValueType::Float ? Rhs.GetFloatValue() : Rhs.GetIntValue())); + } + FSUDSValue operator<(const FSUDSValue& Rhs) const + { + // Don't check types here. We'll fall back on int comparisons which is important for cases where + // a variable hasn't been set + // result is boolean so no need to protect types + return FSUDSValue( + (Type == ESUDSValueType::Float ? GetFloatValue() : GetIntValue()) + < + (Rhs.GetType() == ESUDSValueType::Float ? Rhs.GetFloatValue() : Rhs.GetIntValue())); + + } + FSUDSValue operator==(const FSUDSValue& Rhs) const + { + if (IsNumeric() || Rhs.IsNumeric()) + { + if (GetType() == ESUDSValueType::Float || Rhs.GetType() == ESUDSValueType::Float) + { + // For floats, use tolerance + return FSUDSValue(FMath::IsNearlyEqual( + GetType() == ESUDSValueType::Int ? (float)GetIntValue() : GetFloatValue(), + Rhs.GetType() == ESUDSValueType::Int ? (float)Rhs.GetIntValue() : Rhs.GetFloatValue())); + } + else + { + return FSUDSValue(GetIntValue() == Rhs.GetIntValue()); + } + } + else + { + // no auto conversion here + // however, tolerate unresolved variables, they will return initial values + if (Type != Rhs.Type && Type != ESUDSValueType::Variable && Rhs.Type != ESUDSValueType::Variable) + { + return false; + } + + // Compare using type from whichever one isn't a variable (get values on unset variables will return defaults) + ESUDSValueType UseType = IsVariable() ? Rhs.GetType() : GetType(); + switch (UseType) + { + case ESUDSValueType::Text: + return FSUDSValue(GetTextValue().EqualTo(Rhs.GetTextValue())); + case ESUDSValueType::Boolean: + return FSUDSValue(GetBooleanValue() == Rhs.GetBooleanValue()); + case ESUDSValueType::Gender: + return FSUDSValue(GetGenderValue() == Rhs.GetGenderValue()); + case ESUDSValueType::Variable: + return FSUDSValue(GetVariableNameValue() == Rhs.GetVariableNameValue()); + case ESUDSValueType::Name: + return FSUDSValue(GetNameValue() == Rhs.GetNameValue()); + // deal with int/float again here, this mops up cases where one side is an unset variable + case ESUDSValueType::Int: + return FSUDSValue(GetIntValue() == Rhs.GetIntValue()); + case ESUDSValueType::Float: + return FSUDSValue(GetFloatValue() == Rhs.GetFloatValue()); + default: + break; + }; + } + return FSUDSValue(false); + } + FSUDSValue operator<=(const FSUDSValue& Rhs) const + { + if ((*this < Rhs).GetBooleanValue()) + return FSUDSValue(true); + return (*this == Rhs); + } + + FSUDSValue operator>(const FSUDSValue& Rhs) const + { + return Rhs < *this; + } + + FSUDSValue operator>=(const FSUDSValue& Rhs) const + { + return Rhs <= *this; + } + + FSUDSValue operator!=(const FSUDSValue& Rhs) const + { + return !(*this == Rhs); + } + + FSUDSValue operator&&(const FSUDSValue& Rhs) const + { + // We always let unset variables degrade to false + check(Type == ESUDSValueType::Boolean || Type == ESUDSValueType::Variable); + check(Rhs.Type == ESUDSValueType::Boolean || Rhs.Type == ESUDSValueType::Variable); + return FSUDSValue(GetBooleanValue() && Rhs.GetBooleanValue()); + } + + FSUDSValue operator||(const FSUDSValue& Rhs) const + { + // We always let unset variables degrade to false + check(Type == ESUDSValueType::Boolean || Type == ESUDSValueType::Variable); + check(Rhs.Type == ESUDSValueType::Boolean || Rhs.Type == ESUDSValueType::Variable); + return FSUDSValue(GetBooleanValue() || Rhs.GetBooleanValue()); + } + + SUDS_API friend FArchive& operator<<(FArchive& Ar, FSUDSValue& Value); + SUDS_API friend void operator<<(FStructuredArchive::FSlot Slot, FSUDSValue& Value); + bool Serialize(FArchive& Ar) + { + Ar << *this; + return true; + } + bool Serialize(FStructuredArchive::FSlot Slot) + { + Slot << *this; + return true; + } + + FString ToString() const; + + bool ExportTextItem(FString& ValueStr, FSUDSValue const& DefaultValue, UObject* Parent, int32 PortFlags, UObject* ExportRootScope) const; +}; +template<> +struct TStructOpsTypeTraits : public TStructOpsTypeTraitsBase2 +{ + enum + { + WithSerializer = true, + WithExportTextItem = true + }; +}; + + + diff --git a/Plugins/SUDS/Source/SUDS/SUDS.Build.cs b/Plugins/SUDS/Source/SUDS/SUDS.Build.cs new file mode 100644 index 00000000..b0e14a14 --- /dev/null +++ b/Plugins/SUDS/Source/SUDS/SUDS.Build.cs @@ -0,0 +1,53 @@ +// Copyright Steve Streeting 2022 +// Released under the MIT license https://opensource.org/license/MIT/ +using UnrealBuildTool; + +public class SUDS : ModuleRules +{ + public SUDS(ReadOnlyTargetRules Target) : base(Target) + { + PCHUsage = ModuleRules.PCHUsageMode.UseExplicitOrSharedPCHs; + + PublicIncludePaths.AddRange( + new string[] { + // ... add public include paths required here ... + } + ); + + + PrivateIncludePaths.AddRange( + new string[] { + // ... add other private include paths required here ... + } + ); + + + PublicDependencyModuleNames.AddRange( + new string[] + { + "Core", + // ... add other public dependencies that you statically link with here ... + } + ); + + + PrivateDependencyModuleNames.AddRange( + new string[] + { + "CoreUObject", + "Engine", + "Slate", + "SlateCore", + // ... add private dependencies that you statically link with here ... + } + ); + + + DynamicallyLoadedModuleNames.AddRange( + new string[] + { + // ... add any modules that your module loads dynamically here ... + } + ); + } +} diff --git a/Plugins/SUDS/Source/SUDSEditor/Private/SUDSEditor.cpp b/Plugins/SUDS/Source/SUDSEditor/Private/SUDSEditor.cpp new file mode 100644 index 00000000..b4018894 --- /dev/null +++ b/Plugins/SUDS/Source/SUDSEditor/Private/SUDSEditor.cpp @@ -0,0 +1,65 @@ +// Copyright Steve Streeting 2022 +// Released under the MIT license https://opensource.org/license/MIT/ +#include "SUDSEditor.h" + +#include "ISettingsModule.h" +#include "ISettingsSection.h" +#include "SUDSEditorSettings.h" +#include "SUDSScriptActions.h" +#include "Interfaces/IPluginManager.h" +#include "Styling/SlateStyle.h" +#include "Styling/SlateStyleRegistry.h" + +#define LOCTEXT_NAMESPACE "FSUDSModule" + +void FSUDSEditorModule::StartupModule() +{ + ScriptActions = MakeShared(); + FAssetToolsModule::GetModule().Get().RegisterAssetTypeActions(ScriptActions.ToSharedRef()); + + auto SudsPlugin = IPluginManager::Get().FindPlugin(TEXT("SUDS")); + if (SudsPlugin.IsValid()) + { + const FString IconDir = SudsPlugin->GetBaseDir() + "/Content/Editor/Slate/Icons"; + const FVector2D Sz16 = FVector2D(16.0f, 16.0f); + const FVector2D Sz64 = FVector2D(64.0f, 64.0f); + + StyleSet = MakeShared("SUDSStyleSet"); + StyleSet->Set(TEXT("ClassIcon.SUDSScript"), new FSlateImageBrush(IconDir / TEXT("SUDSScript_16x.png"), Sz16)); + StyleSet->Set(TEXT("ClassThumbnail.SUDSScript"), new FSlateImageBrush(IconDir / TEXT("SUDSScript_64x.png"), Sz64)); + + FSlateStyleRegistry::RegisterSlateStyle(*StyleSet.Get()); + } + + // register settings + ISettingsModule* SettingsModule = FModuleManager::GetModulePtr("Settings"); + if (SettingsModule) + { + ISettingsSectionPtr SettingsSection = SettingsModule->RegisterSettings("Project", "Plugins", "SUDS Editor", + LOCTEXT("SUDSEditorSettingsName", "SUDS Editor"), + LOCTEXT("SUDSEditorSettingsDescription", "Configure the editor parts of SUDS."), + GetMutableDefault() + ); + } + + UE_LOG(LogSUDSEditor, Log, TEXT("SUDS Editor Module Started")) + +} + +void FSUDSEditorModule::ShutdownModule() +{ + if (StyleSet.IsValid()) + { + FSlateStyleRegistry::UnRegisterSlateStyle(*StyleSet.Get()); + } + + if (!FModuleManager::Get().IsModuleLoaded("AssetTools")) return; + FAssetToolsModule::GetModule().Get().UnregisterAssetTypeActions(ScriptActions.ToSharedRef()); + + UE_LOG(LogSUDSEditor, Log, TEXT("SUDS Editor Module Shut Down")) +} + +#undef LOCTEXT_NAMESPACE + +IMPLEMENT_MODULE(FSUDSEditorModule, SUDSEditor) +DEFINE_LOG_CATEGORY(LogSUDSEditor); diff --git a/Plugins/SUDS/Source/SUDSEditor/Private/SUDSEditorScriptTools.cpp b/Plugins/SUDS/Source/SUDSEditor/Private/SUDSEditorScriptTools.cpp new file mode 100644 index 00000000..45216d7b --- /dev/null +++ b/Plugins/SUDS/Source/SUDSEditor/Private/SUDSEditorScriptTools.cpp @@ -0,0 +1,280 @@ +// Copyright Steve Streeting 2022 +// Released under the MIT license https://opensource.org/license/MIT/ +#include "SUDSEditorScriptTools.h" + +#include "SUDSMessageLogger.h" +#include "SUDSScript.h" +#include "SUDSScriptImporter.h" +#include "SUDSScriptNode.h" +#include "SUDSScriptNodeGosub.h" +#include "SUDSScriptNodeSet.h" +#include "SUDSScriptNodeText.h" +#include "EditorFramework/AssetImportData.h" +#include "Misc/FileHelper.h" +#include "Misc/PackageName.h" +#include "Misc/Paths.h" +#include "UObject/Package.h" + +void FSUDSEditorScriptTools::WriteBackTextIDs(USUDSScript* Script, FSUDSMessageLogger& Logger) +{ + const auto& SrcData = Script->AssetImportData->SourceData; + if (SrcData.SourceFiles.Num() == 1) + { + TArray Lines; + FString SourceFile = SrcData.SourceFiles[0].RelativeFilename; + auto Package = Script->GetPackage(); + if (FPaths::IsRelative(SourceFile) && Package) + { + FString PackagePath = FPackageName::LongPackageNameToFilename(FPackageName::GetLongPackagePath(Package->GetPathName())); + SourceFile = FPaths::ConvertRelativePathToFull(PackagePath, SourceFile); + } + if (FFileHelper::LoadFileToStringArray(Lines, *SourceFile)) + { + const int PrevErrs = Logger.NumErrors(); + const bool bHeaderChanges = WriteBackTextIDsFromNodes(Script->GetHeaderNodes(), Lines, Script->GetName(), Logger); + const bool bBodyChanges = WriteBackTextIDsFromNodes(Script->GetNodes(), Lines, Script->GetName(), Logger); + + if (Logger.NumErrors() > PrevErrs) + { + Logger.AddMessage(EMessageSeverity::Info, + FText::FromString(FString::Printf(TEXT("Errors prevented saving updates to %s."), *Script->GetName()))); + } + else if (bHeaderChanges || bBodyChanges) + { + // We need to re-hash file in memory before saving to prevent re-import + int32 Length = 10; + for(const FString& Line : Lines) + { + Length += Line.Len() + UE_ARRAY_COUNT(LINE_TERMINATOR); + } + FString CombinedString; + CombinedString.Reserve(Length); + + for(const FString& Line : Lines) + { + CombinedString += Line; + CombinedString += LINE_TERMINATOR; + } + + // Write source file back; always encode as UTF8 not default ANSI/UTF16 + // Do this before updating asset import data so it picks up new file timestamp + FFileHelper::SaveStringToFile(FStringView(CombinedString), *SourceFile, FFileHelper::EEncodingOptions::ForceUTF8); + + /* BEGIN try to prevent re-import prompt + * This unfortunately didn't work, so removed & let it reimport + + const FMD5Hash FileHash = FSUDSScriptImporter::CalculateHash(*CombinedString, CombinedString.Len()); + Script->AssetImportData->Update(SourceFile, FileHash); + + // Need to mark asset dirty since hash has changed + // Try to find the outer package so we can dirty it up + if (Script->GetOuter()) + { + Script->GetOuter()->MarkPackageDirty(); + } + else + { + Script->MarkPackageDirty(); + } + + // Even this doesn't work: + GUnrealEd->AutoReimportManager->IgnoreFileModification(SourceFile); + + * END try to prevent re-import + */ + + Logger.AddMessage(EMessageSeverity::Info, + FText::FromString(FString::Printf(TEXT("Successfully updated %s"), *SourceFile))); + + } + else + { + Logger.AddMessage(EMessageSeverity::Info, + FText::FromString(FString::Printf(TEXT("No changes were required to %s"), *SourceFile))); + } + + } + else + { + Logger.AddMessage(EMessageSeverity::Error, + FText::FromString(FString::Printf(TEXT("Error opening source asset %s"), *SourceFile))); + } + } + else + { + Logger.AddMessage(EMessageSeverity::Error, + FText::FromString( + FString::Printf(TEXT("No source files associated with asset %s"), *Script->GetName()))); + } +} + +bool FSUDSEditorScriptTools::WriteBackTextIDsFromNodes(const TArray Nodes, TArray& Lines, const FString& NameForErrors, FSUDSMessageLogger& Logger) +{ + bool bAnyChanges = false; + // For each speaker line, set line and choice edge, use source line no to append text ID + for (const auto& N : Nodes) + { + if (N->GetNodeType() == ESUDSScriptNodeType::Text) + { + if (const auto* TN = Cast(N)) + { + bAnyChanges = WriteBackTextID(TN->GetText(), TN->GetSourceLineNo(), Lines, NameForErrors, Logger) || bAnyChanges; + } + } + else if (N->GetNodeType() == ESUDSScriptNodeType::SetVariable) + { + if (const auto* SN = Cast(N)) + { + if (SN->GetExpression().IsTextLiteral()) + { + FText Literal = SN->GetExpression().GetTextLiteralValue(); + + bAnyChanges = WriteBackTextID(Literal, SN->GetSourceLineNo(), Lines, NameForErrors, Logger) || bAnyChanges; + } + } + + } + else if (N->GetNodeType() == ESUDSScriptNodeType::Choice) + { + // Edges + for (auto& Edge : N->GetEdges()) + { + bAnyChanges = WriteBackTextID(Edge.GetText(), Edge.GetSourceLineNo(), Lines, NameForErrors, Logger) || bAnyChanges; + } + } + else if (N->GetNodeType() == ESUDSScriptNodeType::Gosub) + { + if (const auto* GN = Cast(N)) + // Need to write back Gosub IDs so that saved return stacks work + bAnyChanges = WriteBackGosubID(GN->GetGosubID(), GN->GetSourceLineNo(), Lines, NameForErrors, Logger) || bAnyChanges; + } + } + + return bAnyChanges; +} + +bool FSUDSEditorScriptTools::WriteBackTextID(const FText& AssetText, int LineNo, TArray& Lines, const FString& NameForErrors, FSUDSMessageLogger& Logger) +{ + if (AssetText.IsEmpty()) + return false; + + // Line numbers are 1-based + const int Idx = LineNo - 1; + + if (!Lines.IsValidIndex(Idx)) + { + Logger.AddMessage(EMessageSeverity::Error, + FText::FromString(FString::Printf( + TEXT("Cannot write back TextID to '%s', source line number %d is invalid (Text: '%s')"), + *NameForErrors, + LineNo, + *AssetText.ToString()))); + return false; + } + + const FString& SourceLine = Lines[Idx]; + const FString TextID = SUDS_GET_TEXT_KEY(AssetText); + FString ExistingTextID; + int ExistingNum; + FStringView SourceLineView(SourceLine); + const bool bFoundExisting = FSUDSScriptImporter::RetrieveTextIDFromLine(SourceLineView, ExistingTextID, ExistingNum); + + // Existing TextID - replace if already there + if (!bFoundExisting || ExistingTextID != TextID) + { + if (TextIDCheckMatch(AssetText, SourceLine)) + { + FString Prefix(SourceLineView); + FString UpdatedLine = FString::Printf(TEXT("%s %s"), *Prefix, *TextID); + Lines[Idx] = UpdatedLine; + return true; + } + else + { + Logger.AddMessage(EMessageSeverity::Error, + FText::FromString(FString::Printf(TEXT( + "Tried to set TextID on line %d of %s but source file did not contain expected text '%s'" + ), + LineNo, + *NameForErrors, + *AssetText.ToString()))); + return false; + } + } + // Same, no need to change + return false; + +} + +bool FSUDSEditorScriptTools::TextIDCheckMatch(const FText& AssetText, const FString& SourceLine) +{ + // Text from our asset must be present in the text in the source line + // However, in case this is a multi-line string, take the first line only + FString AssetStr = AssetText.ToString(); + { + FString L, R; + if (AssetStr.Split("\n", &L, &R)) + { + AssetStr = L.TrimStartAndEnd(); + } + } + + // Bear in mind that this line might be a text line, a choice or a set line + // therefore we only check if the source line contains the asset text + + return SourceLine.Contains(AssetStr); + +} + +bool FSUDSEditorScriptTools::WriteBackGosubID(const FString& GosubID, + int LineNo, + TArray& Lines, + const FString& NameForErrors, + FSUDSMessageLogger& Logger) +{ + + // Line numbers are 1-based + const int Idx = LineNo - 1; + + if (!Lines.IsValidIndex(Idx)) + { + Logger.AddMessage(EMessageSeverity::Error, + FText::FromString(FString::Printf( + TEXT("Cannot write back GosubID to '%s', source line number %d is invalid"), + *NameForErrors, + LineNo))); + return false; + } + + const FString& SourceLine = Lines[Idx]; + FString ExistingID; + int ExistingNum; + FStringView SourceLineView(SourceLine); + const bool bFoundID = FSUDSScriptImporter::RetrieveGosubIDFromLine(SourceLineView, ExistingID, ExistingNum); + + if (!bFoundID || ExistingID != GosubID) + { + // Check it's a gosub line + if (SourceLine.Contains(TEXT("gosub")) || SourceLine.Contains(TEXT("go sub"))) + { + FString Prefix(SourceLineView); + FString UpdatedLine = FString::Printf(TEXT("%s %s"), *Prefix, *GosubID); + Lines[Idx] = UpdatedLine; + return true; + } + else + { + Logger.AddMessage(EMessageSeverity::Error, + FText::FromString(FString::Printf(TEXT( + "Tried to set GosubID on line %d of %s but source file did not have a gosubu on that line" + ), + LineNo, + *NameForErrors))); + return false; + + } + } + // Same, no need to change + return false; + +} diff --git a/Plugins/SUDS/Source/SUDSEditor/Private/SUDSEditorSettings.cpp b/Plugins/SUDS/Source/SUDSEditor/Private/SUDSEditorSettings.cpp new file mode 100644 index 00000000..e1552c54 --- /dev/null +++ b/Plugins/SUDS/Source/SUDSEditor/Private/SUDSEditorSettings.cpp @@ -0,0 +1,48 @@ +// Copyright Steve Streeting 2022 +// Released under the MIT license https://opensource.org/license/MIT/ +#include "SUDSEditorSettings.h" +#include "Misc/Paths.h" + +bool USUDSEditorSettings::ShouldGenerateVoiceAssets(const FString& PackagePath) const +{ + if (AlwaysAutoGenerateVoiceOverAssetsOnImport) + return true; + + for (auto Dir : DirectoriesToAutoGenerateVoiceOverAssetsOnImport) + { + if (FPaths::IsUnderDirectory(PackagePath, Dir.Path)) + { + return true; + } + } + return false; +} + +FString USUDSEditorSettings::GetVoiceOutputDir(const FString& PackagePath, const FString& ScriptName) const +{ + return GetOutputDir(DialogueVoiceAssetLocation, DialogueVoiceAssetSharedDir.Path, PackagePath, ScriptName); +} + +FString USUDSEditorSettings::GetWaveOutputDir(const FString& PackagePath, const FString& ScriptName) const +{ + return GetOutputDir(DialogueWaveAssetLocation, DialogueWaveAssetSharedDir.Path, PackagePath, ScriptName); +} + +FString USUDSEditorSettings::GetOutputDir(ESUDSAssetLocation Location, + const FString& SharedPath, + const FString& PackagePath, + const FString& ScriptName) +{ + switch(Location) + { + default: + case ESUDSAssetLocation::SharedDirectory: + return SharedPath; + case ESUDSAssetLocation::SharedDirectorySubdir: + return FPaths::Combine(SharedPath, ScriptName); + case ESUDSAssetLocation::ScriptDirectory: + return PackagePath; + case ESUDSAssetLocation::ScriptDirectorySubdir: + return FPaths::Combine(PackagePath, ScriptName); + } +} diff --git a/Plugins/SUDS/Source/SUDSEditor/Private/SUDSEditorToolkit.cpp b/Plugins/SUDS/Source/SUDSEditor/Private/SUDSEditorToolkit.cpp new file mode 100644 index 00000000..8b61ff5a --- /dev/null +++ b/Plugins/SUDS/Source/SUDSEditor/Private/SUDSEditorToolkit.cpp @@ -0,0 +1,1590 @@ +// Copyright Steve Streeting 2022 +// Released under the MIT license https://opensource.org/license/MIT/ +#include "SUDSEditorToolkit.h" + +#include "EditorReimportHandler.h" +#include "IDetailRootObjectCustomization.h" +#include "IDocumentation.h" +#include "SUDSDialogue.h" +#include "SUDSEditorScriptTools.h" +#include "SUDSEditorVoiceOverTools.h" +#include "SUDSLibrary.h" +#include "SUDSMessageLogger.h" +#include "SUDSScript.h" +#include "SUDSScriptNodeText.h" +#include "SUDSSubsystem.h" +#include "Framework/Text/SlateTextRun.h" +#include "Misc/MessageDialog.h" +#include "Widgets/Docking/SDockTab.h" +#include "Widgets/Input/SButton.h" +#include "Widgets/Input/SCheckBox.h" +#include "Widgets/Input/SComboButton.h" +#include "Widgets/Input/SEditableTextBox.h" +#include "Widgets/Input/SMultiLineEditableTextBox.h" +#include "Widgets/Input/SNumericEntryBox.h" +#include "Widgets/SToolTip.h" +#include "Widgets/Views/SListView.h" +#include "Toolkits/AssetEditorToolkit.h" +#include "PropertyEditorModule.h" + +const FName NAME_SpeakerLine("SpeakerLine"); +const FName NAME_Choice("Choice"); +const FName NAME_VariableSet("VariableSet"); +const FName NAME_VariableEdit("VariableEdit"); +const FName NAME_SelectEval("Condition"); +const FName NAME_RandomEval("Random"); +const FName NAME_Event("Event"); +const FName NAME_Start("Start"); +const FName NAME_Finish("Finish"); + +FName FSUDSEditorToolkit::DialogueOutputTabName("SUDSDialogueTab"); +FName FSUDSEditorToolkit::VariablesTabName("SUDSVariablesTab"); +FName FSUDSEditorToolkit::DetailsTabName("SUDSDetailsTab"); +FName FSUDSEditorToolkit::LogTabName("SUDSLogTab"); + + +class FSUDSDetailRootObjectCustomization : public IDetailRootObjectCustomization +{ +public: + virtual TSharedPtr CustomizeObjectHeader(const FDetailsObjectSet& InRootObjectSet, + const TSharedPtr& InTableRow) override + { + auto Ret = SNew(STextBlock) + .Margin(FMargin(10,5)) + .TextStyle(&FAppStyle::Get().GetWidgetStyle("SmallText")); + if (auto TN = Cast(InRootObjectSet.RootObjects[0])) + { + Ret->SetText(FText::FromString(FString::Printf(TEXT("Speaker Line - ID: %s Source Line: %d"), *TN->GetTextID(), TN->GetSourceLineNo()))); + } + return Ret; + } + + virtual bool ShouldDisplayHeader(const FDetailsObjectSet& InRootObjectSet) const override + { + return InRootObjectSet.RootObjects[0]->IsA(USUDSScriptNodeText::StaticClass()); + } +}; + +void FSUDSEditorToolkit::InitEditor(const TArray& InObjects) +{ + if (InObjects.Num() > 0) + { + Script = Cast(InObjects[0]); + + ReimportDelegateHandle = FReimportManager::Instance()->OnPostReimport().AddRaw(this, &FSUDSEditorToolkit::OnPostReimport); + + const TSharedRef Layout = FTabManager::NewLayout("SUDSEditorLayout_v3") + ->AddArea + ( + FTabManager::NewPrimaryArea()->SetOrientation(Orient_Horizontal) + ->Split + ( + FTabManager::NewSplitter() + ->SetSizeCoefficient(0.75f) + ->SetOrientation(Orient_Horizontal) + ->Split + ( + FTabManager::NewSplitter() + ->SetSizeCoefficient(0.6f) + ->SetOrientation(Orient_Vertical) + ->Split + ( + + + FTabManager::NewStack() + ->SetSizeCoefficient(0.8f) + ->AddTab("SUDSDialogueTab", ETabState::OpenedTab) + ) + ->Split + ( + FTabManager::NewStack() + ->SetSizeCoefficient(0.4f) + ->AddTab("SUDSLogTab", ETabState::OpenedTab) + ) + ) + ->Split + ( + FTabManager::NewSplitter() + ->SetSizeCoefficient(0.25f) + ->SetOrientation(Orient_Vertical) + ->Split + ( + FTabManager::NewStack() + ->SetSizeCoefficient(0.6f) + ->AddTab("SUDSVariablesTab", ETabState::OpenedTab) + ) + ->Split + ( + FTabManager::NewStack() + ->SetSizeCoefficient(0.4f) + ->AddTab("SUDSDetailsTab", ETabState::OpenedTab) + ) + ) + ) + ); + FAssetEditorToolkit::InitAssetEditor(EToolkitMode::Standalone, {}, "SUDSEditor", Layout, true, true, InObjects); + + } +} + +void FSUDSEditorToolkit::RegisterTabSpawners(const TSharedRef& InTabManager) +{ + FAssetEditorToolkit::RegisterTabSpawners(InTabManager); + + WorkspaceMenuCategory = InTabManager->AddLocalWorkspaceMenuCategory(INVTEXT("SUDS Editor")); + + InTabManager->RegisterTabSpawner(DialogueOutputTabName, FOnSpawnTab::CreateLambda([this](const FSpawnTabArgs&) + { + OutputListView = SNew(SListView>) +#if ENGINE_MINOR_VERSION < 5 + .ItemHeight(24) +#endif + .SelectionMode(ESelectionMode::None) + .ListItemsSource(&OutputRows) + .OnGenerateRow(this, &FSUDSEditorToolkit::OnGenerateRowForOutput) + .HeaderRow( + SNew(SHeaderRow) + + SHeaderRow::Column("PrefixHeader") + .FillSized(PrefixColumnWidth) + [ + SNew(SHorizontalBox) + +SHorizontalBox::Slot() + .AutoWidth() + .VAlign(VAlign_Center) + [ + SNew( STextBlock ) + .Text( INVTEXT("") ) + ] + ] + + SHeaderRow::Column("LineHeader") + .FillWidth(1.0f) + [ + SNew(SHorizontalBox) + +SHorizontalBox::Slot() + .AutoWidth() + .VAlign(VAlign_Center) + [ + SNew( STextBlock ) + .Text( INVTEXT("") ) + ] + ] + ); + + + ChoicesBox = SNew(SVerticalBox); + + return SNew(SDockTab) + [ + SNew(SVerticalBox) + +SVerticalBox::Slot() + .FillHeight(1) + [ + OutputListView.ToSharedRef() + ] + +SVerticalBox::Slot() + .AutoHeight() + .HAlign(HAlign_Left) + .Padding(30, 15, 30, 15) + [ + ChoicesBox.ToSharedRef() + ] + ]; + })) + .SetDisplayName(INVTEXT("Dialogue Output")) + .SetGroup(WorkspaceMenuCategory.ToSharedRef()); + + InTabManager->RegisterTabSpawner(VariablesTabName, FOnSpawnTab::CreateLambda([this](const FSpawnTabArgs&) + { + VariablesListView = SNew(SListView>) +#if ENGINE_MINOR_VERSION < 5 + .ItemHeight(28) +#endif + .SelectionMode(ESelectionMode::None) + .ListItemsSource(&VariableRows) + .OnGenerateRow(this, &FSUDSEditorToolkit::OnGenerateRowForVariable) + .HeaderRow( + SNew(SHeaderRow) + + SHeaderRow::Column("NameHeader") + .FillSized(VarColumnWidth) + .VAlignCell(VAlign_Center) + [ + SNew(SHorizontalBox) + + SHorizontalBox::Slot() + .AutoWidth() + .VAlign(VAlign_Center) + [ + SNew(STextBlock) + .Text(INVTEXT("Name")) + ] + ] + + SHeaderRow::Column("ValueHeader") + .FillWidth(1.0f) + .VAlignCell(VAlign_Fill) + [ + SNew(SHorizontalBox) + + SHorizontalBox::Slot() + .AutoWidth() + .VAlign(VAlign_Center) + [ + SNew(STextBlock) + .Text(INVTEXT("Value")) + ] + ] + + ); + + // To ensure globals are pre-populated + UpdateVariables(); + + // Possibly use a SPropertyTable with a custom IPropertyTable to implement variable binding + return SNew(SDockTab) + [ + SNew(SVerticalBox) + + SVerticalBox::Slot() + .VAlign(VAlign_Top) + .AutoHeight() + [ + SNew(SHorizontalBox) + + SHorizontalBox::Slot() + .HAlign(HAlign_Right) + .Padding(2) + [ + SNew(SButton) + .Text(INVTEXT("Add Variable")) + .OnClicked(this, &FSUDSEditorToolkit::AddVariableClicked) + ] + ] + + SVerticalBox::Slot() + .FillHeight(1.0) + [ + VariablesListView.ToSharedRef() + ] + ]; + })) + .SetDisplayName(INVTEXT("Variables")) + .SetGroup(WorkspaceMenuCategory.ToSharedRef()); + + InTabManager->RegisterTabSpawner(DetailsTabName, FOnSpawnTab::CreateLambda([this](const FSpawnTabArgs&) + { + FPropertyEditorModule& PropertyEditorModule = FModuleManager::Get().LoadModuleChecked("PropertyEditor"); + FDetailsViewArgs DetailsViewArgs; + DetailsViewArgs.bShowOptions = false; + DetailsViewArgs.bUpdatesFromSelection = false; + DetailsViewArgs.bShowPropertyMatrixButton = false; + DetailsViewArgs.bAllowSearch = false; + DetailsViewArgs.bAllowMultipleTopLevelObjects = true; + DetailsViewArgs.bShowObjectLabel = false; + DetailsViewArgs.bHideSelectionTip = true; + auto DetailView = PropertyEditorModule.CreateDetailView(DetailsViewArgs); + DetailView->SetRootObjectCustomizationInstance(MakeShareable(new FSUDSDetailRootObjectCustomization())); + TArray ObjectsInDetailView; + ObjectsInDetailView.Add(Script); + for (auto Node : Script->GetNodes()) + { + if (auto TNode = Cast(Node)) + { + ObjectsInDetailView.Add(TNode); + } + } + DetailView->SetObjects(ObjectsInDetailView, true, true); + // Possibly use a SPropertyTable with a custom IPropertyTable to implement variable binding + return SNew(SDockTab) + [ + SNew(SVerticalBox) + + SVerticalBox::Slot() + .VAlign(VAlign_Top) + [ + DetailView + ] + ]; + })) + .SetDisplayName(INVTEXT("Details")) + .SetGroup(WorkspaceMenuCategory.ToSharedRef()); + + + InTabManager->RegisterTabSpawner(LogTabName, FOnSpawnTab::CreateLambda([this](const FSpawnTabArgs&) + { + TraceLog = SNew(SSUDSTraceLog); + return SNew(SDockTab) + [ + TraceLog.ToSharedRef() + ]; + + })) + .SetDisplayName(INVTEXT("Trace Log")) + .SetGroup(WorkspaceMenuCategory.ToSharedRef()); + + + // Set up the toolbar + FSUDSToolbarCommands::Register(); + + const TWeakPtr WeakToolkit = this->AsShared(); + + TSharedRef ToolbarExtender = MakeShareable(new FExtender); + + ToolbarExtender->AddToolBarExtension( + "Asset", + EExtensionHook::After, + GetToolkitCommands(), + FToolBarExtensionDelegate::CreateRaw(this, &FSUDSEditorToolkit::ExtendToolbar, TWeakPtr(TabManager->GetOwnerTab()))); + AddToolbarExtender(ToolbarExtender); + + GetToolkitCommands()->MapAction(FSUDSToolbarCommands::Get().StartDialogue, + FExecuteAction::CreateSP(this, &FSUDSEditorToolkit::StartDialogue), + FCanExecuteAction()); + GetToolkitCommands()->MapAction(FSUDSToolbarCommands::Get().WriteBackTextIDs, + FExecuteAction::CreateSP(this, &FSUDSEditorToolkit::WriteBackTextIDs), + FCanExecuteAction()); + GetToolkitCommands()->MapAction(FSUDSToolbarCommands::Get().GenerateVOAssets, + FExecuteAction::CreateSP(this, &FSUDSEditorToolkit::GenerateVOAssets), + FCanExecuteAction()); + + //RegenerateMenusAndToolbars(); + + +} + +void FSUDSEditorToolkit::EnsureTabsVisible() +{ + // Hard requirement for the dialogue output tab to be open, so restore it if the user had + // closed it (probably by accident) + if (!TabManager->FindExistingLiveTab(DialogueOutputTabName)) + { + TabManager->TryInvokeTab(DialogueOutputTabName); + } + +} + +void FSUDSEditorToolkit::ExtendToolbar(FToolBarBuilder& ToolbarBuilder, TWeakPtr Tab) +{ + if (!Tab.IsValid()) + { + return; + } + + ToolbarBuilder.BeginSection("CachedState"); + { + ToolbarBuilder.AddToolBarButton(FSUDSToolbarCommands::Get().StartDialogue, + NAME_None, TAttribute(), TAttribute(), + FSlateIcon( +#if ENGINE_MAJOR_VERSION == 5 && ENGINE_MINOR_VERSION > 0 + FAppStyle::GetAppStyleSetName(), +#else + FEditorStyle::GetStyleSetName(), +#endif + TEXT("Icons.Toolbar.Play"))); + + TSharedRef LabelSelectionBox = SNew(SComboButton) + .OnGetMenuContent(this, &FSUDSEditorToolkit::GetStartLabelMenu) + .ButtonContent() + [ + SNew(STextBlock) + .ToolTipText( INVTEXT("Choose where to start dialogue from") ) + .Text(this, &FSUDSEditorToolkit::GetSelectedStartLabel ) + ]; + + ToolbarBuilder.AddWidget(LabelSelectionBox); + + ToolbarBuilder.AddWidget(SNew(STextBlock) + .Text(FText::FromString(TEXT("Reset Variables:"))) + .Margin(FMargin(10, 7, 10, 5))); + TSharedPtr ResetTooltip = IDocumentation::Get()->CreateToolTip( + FText::FromString(TEXT( + "Whether to reset variable state created by the script (note: vars manually created in this editor are never reset, delete them yourself)")), + NULL, + "", + TEXT("ResetVars")); + TSharedRef ResetVarsCheckbox = SNew(SCheckBox) + .ToolTip(ResetTooltip) + .IsChecked(this, &FSUDSEditorToolkit::GetResetVarsCheckState) + .OnCheckStateChanged(this, &FSUDSEditorToolkit::OnResetVarsCheckStateChanged); + ToolbarBuilder.AddWidget(ResetVarsCheckbox); + + } + ToolbarBuilder.EndSection(); + + ToolbarBuilder.AddSeparator(); + + ToolbarBuilder.BeginSection("CachedState"); + { + ToolbarBuilder.AddToolBarButton(FSUDSToolbarCommands::Get().WriteBackTextIDs, + NAME_None, TAttribute(), TAttribute(), + FSlateIcon( +#if ENGINE_MAJOR_VERSION == 5 && ENGINE_MINOR_VERSION > 0 + FAppStyle::GetAppStyleSetName(), +#else + FEditorStyle::GetStyleSetName(), +#endif + TEXT("Icons.Toolbar.Details"))); + + ToolbarBuilder.AddToolBarButton(FSUDSToolbarCommands::Get().GenerateVOAssets, + NAME_None, TAttribute(), TAttribute(), + FSlateIcon( +#if ENGINE_MAJOR_VERSION == 5 && ENGINE_MINOR_VERSION > 0 + FAppStyle::GetAppStyleSetName(), +#else + FEditorStyle::GetStyleSetName(), +#endif + TEXT("Icons.Toolbar.Export"))); + + } + ToolbarBuilder.EndSection(); + +} + +TSharedRef FSUDSEditorToolkit::GetStartLabelMenu() +{ + FMenuBuilder MenuBuilder(true, NULL); + + const FUIAction StartAction(FExecuteAction::CreateSP(this, &FSUDSEditorToolkit::OnStartLabelSelected, FName(NAME_None))); + MenuBuilder.AddMenuEntry(INVTEXT("Beginning"), INVTEXT("Start from the beginning of the dialogue"), FSlateIcon(), StartAction); + MenuBuilder.AddSeparator(); + + if (IsValid(Script)) + { + for (auto& LabelPair : Script->GetLabelList()) + { + FUIAction LabelAction(FExecuteAction::CreateSP(this, &FSUDSEditorToolkit::OnStartLabelSelected, LabelPair.Key)); + MenuBuilder.AddMenuEntry(FText::FromName(LabelPair.Key), FText(), FSlateIcon(), LabelAction); + } + } + + + return MenuBuilder.MakeWidget(); +} + +void FSUDSEditorToolkit::OnStartLabelSelected(FName Label) +{ + StartLabel = Label; +} + +FText FSUDSEditorToolkit::GetSelectedStartLabel() const +{ + return FText::Format(INVTEXT("From: {0}"), + StartLabel.IsNone() ? INVTEXT("Beginning") : FText::FromName(StartLabel)); +} + +ECheckBoxState FSUDSEditorToolkit::GetResetVarsCheckState() const +{ + return bResetVarsOnStart ? ECheckBoxState::Checked : ECheckBoxState::Unchecked; +} + +void FSUDSEditorToolkit::OnResetVarsCheckStateChanged(ECheckBoxState NewState) +{ + bResetVarsOnStart = NewState == ECheckBoxState::Checked; +} + +void FSUDSEditorToolkit::UnregisterTabSpawners(const TSharedRef& InTabManager) +{ + FAssetEditorToolkit::UnregisterTabSpawners(InTabManager); + + InTabManager->UnregisterTabSpawner("SUDSDialogueTab"); + InTabManager->UnregisterTabSpawner("SUDSVariablesTab"); + InTabManager->UnregisterTabSpawner("SUDSLogTab"); +} + +FText FSUDSEditorToolkit::GetBaseToolkitName() const +{ + return INVTEXT("SUDS Script Editor"); +} + +FName FSUDSEditorToolkit::GetToolkitFName() const +{ + return "SUDSScriptEditor"; +} + +FLinearColor FSUDSEditorToolkit::GetWorldCentricTabColorScale() const +{ + return FLinearColor(); +} + +FString FSUDSEditorToolkit::GetWorldCentricTabPrefix() const +{ + return "SUDS Script "; +} + +void FSUDSEditorToolkit::UserEditVariable(const FName& Name, FSUDSValue Value) +{ + FName GlobalName; + if (USUDSLibrary::IsDialogueVariableGlobal(Name, GlobalName)) + { + USUDSSubsystem::Test_DummyGlobalVariables.Add(GlobalName, Value); + OnDialogueUserEditedVar(nullptr, Name, Value); + } + else + { + // Update manual overrides if it's one of these + if (ManualOverrideVariables.Contains(Name)) + { + ManualOverrideVariables[Name] = Value; + } + + // This will cause a refresh + if (Dialogue) + Dialogue->SetVariable(Name, Value); + else + { + // This won't get called if dialogue is not initialised, so call it to get log & refresh + OnDialogueUserEditedVar(nullptr, Name, Value); + } + } +} + +void FSUDSEditorToolkit::DeleteVariable(const FName& Name) +{ + FName GlobalName; + if (USUDSLibrary::IsDialogueVariableGlobal(Name, GlobalName)) + { + USUDSSubsystem::Test_DummyGlobalVariables.Remove(GlobalName); + } + else + { + ManualOverrideVariables.Remove(Name); + if (Dialogue) + Dialogue->UnSetVariable(Name); + } + + UpdateVariables(); +} + +void FSUDSEditorToolkit::OnClose() +{ + FAssetEditorToolkit::OnClose(); + FReimportManager::Instance()->OnPostReimport().Remove(ReimportDelegateHandle); + ReimportDelegateHandle.Reset(); + + DestroyDialogue(); + +} + +void FSUDSEditorToolkit::StartDialogue() +{ + // In case the user closed important tabs + EnsureTabsVisible(); + Clear(); + if (!Dialogue) + { + // Custom creation of USUDSDialogue, we want it to exist only for this window. + // Mark as a root object (we'll clear it on close) + { + // Have to guard vs GC + FGCScopeGuard GCGuard; + const FName Name = MakeUniqueObjectName(GetTransientPackage(), + USUDSDialogue::StaticClass(), + Script->GetFName()); + Dialogue = NewObject(GetTransientPackage(), + Name, RF_Transient | RF_MarkAsRootSet); + } + Dialogue->Initialise(Script); + + Dialogue->InternalOnChoice.BindSP(this, &FSUDSEditorToolkit::OnDialogueChoice); + Dialogue->InternalOnEvent.BindSP(this, &FSUDSEditorToolkit::OnDialogueEvent); + Dialogue->InternalOnFinished.BindSP(this, &FSUDSEditorToolkit::OnDialogueFinished); + Dialogue->InternalOnProceeding.BindSP(this, &FSUDSEditorToolkit::OnDialogueProceeding); + Dialogue->InternalOnStarting.BindSP(this, &FSUDSEditorToolkit::OnDialogueStarting); + Dialogue->InternalOnSpeakerLine.BindSP(this, &FSUDSEditorToolkit::OnDialogueSpeakerLine); + Dialogue->InternalOnSetVar.BindSP(this, &FSUDSEditorToolkit::OnDialogueSetVar); + Dialogue->InternalOnSetVarByCode.BindSP(this, &FSUDSEditorToolkit::OnDialogueUserEditedVar); + Dialogue->InternalOnSelectEval.BindSP(this, &FSUDSEditorToolkit::OnDialogueSelectEval); + + + } + else + { + // Reset things manually so we can set manual override vars + if (bResetVarsOnStart) + Dialogue->ResetState(); + } + + // Set manual override vars before we start + for (auto& Pair : ManualOverrideVariables) + { + Dialogue->SetVariable(Pair.Key, Pair.Value); + } + + Dialogue->Restart(false, StartLabel); + + + UpdateVariables(); + +} + +void FSUDSEditorToolkit::Clear() +{ + OutputRows.Empty(); + if (OutputListView.IsValid()) + { + OutputListView->RequestListRefresh(); + } + VariableRows.Empty(); + if (VariablesListView.IsValid()) + { + VariablesListView->RequestListRefresh(); + } + //TraceLog->ClearMessages(); + +} + +void FSUDSEditorToolkit::DestroyDialogue() +{ + if (Dialogue) + { + // Handle garbage collection of our UObject + { + FGCScopeGuard GCGuard; + Dialogue->RemoveFromRoot(); + Dialogue->MarkAsGarbage(); + } + Dialogue = nullptr; + CollectGarbage(GARBAGE_COLLECTION_KEEPFLAGS); + } +} + +void FSUDSEditorToolkit::UpdateOutput() +{ + if (OutputListView.IsValid()) + { + OutputListView->RequestListRefresh(); + OutputListView->ScrollToBottom(); + } +} + +void FSUDSEditorToolkit::UpdateChoiceButtons() +{ + USUDSDialogue* D = Dialogue; + ChoicesBox->ClearChildren(); + if (!D->IsEnded()) + { + if (D->IsSimpleContinue()) + { + ChoicesBox->AddSlot() + .Padding(0, 0 ,0 , 5) + [ + SNew(SButton) + .HAlign(HAlign_Left) + .Text(INVTEXT("(Continue...)")) + .OnClicked_Lambda([D]() + { + D->Continue(); + return FReply::Handled(); + }) + ]; + } + else + { + for (int i = 0; i < D->GetNumberOfChoices(); ++i) + { + ChoicesBox->AddSlot() + .Padding(0, 0 ,0 , 5) + [ + SNew(SButton) + .HAlign(HAlign_Left) + .Text(D->GetChoiceText(i)) + .OnClicked_Lambda([D, i]() + { + D->Choose(i); + return FReply::Handled(); + }) + ]; + } + + } + } + +} + +void FSUDSEditorToolkit::OnDialogueChoice(USUDSDialogue* D, int ChoiceIndex, int LineNo) +{ + if (!D->IsSimpleContinue()) + { + AddDialogueStep(NAME_Choice, LineNo, + FText::Format(INVTEXT("[{1}] {0}"), D->GetChoiceText(ChoiceIndex), ChoiceIndex), + INVTEXT("Choice")); + + } +} + +void FSUDSEditorToolkit::OnDialogueEvent(USUDSDialogue* D, FName EventName, const TArray& Args, int LineNo) +{ +#if ENGINE_MAJOR_VERSION ==5 && ENGINE_MINOR_VERSION >= 8 + TStringBuilder<256> B; +#else + FStringBuilderBase B; +#endif + if (Args.Num() > 0) + { + for (auto& Arg : Args) + { + if (B.Len() > 0) + { + B.Appendf(TEXT(", %s"), *Arg.ToString()); + } + else + { + B.Append("( "); + B.Append(Arg.ToString()); + } + } + B.Append(" )"); + } + FText ArgText = FText::FromString(B.ToString()); + AddDialogueStep(NAME_Event, LineNo, + FText::FormatOrdered(INVTEXT("{0} {1}"), FText::FromName(EventName), ArgText), + INVTEXT("Event")); + +} + +void FSUDSEditorToolkit::OnDialogueFinished(USUDSDialogue* D) +{ + AddDialogueStep(NAME_Finish, 0, + INVTEXT("Dialogue Finished"), + INVTEXT("Finish")); + UpdateChoiceButtons(); +} + +void FSUDSEditorToolkit::OnDialogueProceeding(USUDSDialogue* D) +{ +} + +void FSUDSEditorToolkit::OnDialogueStarting(USUDSDialogue* D, FName LabelName) +{ + FString LabelStr = LabelName.IsNone() ? FString("beginning") : LabelName.ToString(); + AddDialogueStep(NAME_Start, 0, + FText::Format(INVTEXT("Starting from {0}"), FText::FromString(LabelStr)), + INVTEXT("Start")); +} + +void FSUDSEditorToolkit::OnDialogueSpeakerLine(USUDSDialogue* D, int LineNo) +{ + AddDialogueStep(NAME_SpeakerLine, LineNo , D->GetText(),D->GetSpeakerDisplayName()); + UpdateChoiceButtons(); + +} + +void FSUDSEditorToolkit::AddOutputRow(const FText& Prefix, + const FText& Line, + const FSlateColor& PrefixColour, + const FSlateColor& LineColour) +{ + // Stupid table changes any colours I give it, white = dark grey + // Can't figure out where it's coming from, I f**king hate Slate + const FSlateColor BgColour = (OutputRows.Num() % 2) > 0 ? FSlateColor(FLinearColor::White) : FSlateColor(FLinearColor(0.9f, 0.9f, 0.9f, 1)); + OutputRows.Add(MakeShareable( + new FSUDSEditorOutputRow(Prefix, Line, PrefixColour, LineColour, BgColour))); + UpdateOutput(); + +} + +void FSUDSEditorToolkit::AddTraceLogRow(const FName& Category, int SourceLineNo, const FString& Message) +{ + FSlateColor Colour = GetColourForCategory(Category); + + if (TraceLog) + { + TraceLog->AppendMessage(Category, SourceLineNo, Message, Colour); + } +} + +FSlateColor FSUDSEditorToolkit::GetColourForCategory(const FName& Category) +{ + if (Category == NAME_SpeakerLine) + { + return SpeakerColour; + } + else if (Category == NAME_Choice) + { + return ChoiceColour; + } + else if (Category == NAME_Event) + { + return EventColour; + } + else if (Category == NAME_VariableSet) + { + return VarSetColour; + } + else if (Category == NAME_VariableEdit) + { + return VarEditColour; + } + else if (Category == NAME_Start) + { + return StartColour; + } + else if (Category == NAME_Finish) + { + return FinishColour; + } + else if (Category == NAME_SelectEval) + { + return SelectColour; + } + + return FSlateColor::UseForeground(); + +} + +void FSUDSEditorToolkit::AddDialogueStep(const FName& Category, int SourceLineNo, const FText& Description, const FText& Prefix) +{ + if (Category == NAME_SpeakerLine) + { + AddOutputRow(Prefix, Description, SpeakerColour, FSlateColor::UseForeground()); + AddTraceLogRow(Category, SourceLineNo, FString::Printf(TEXT("%s: %s"), *Prefix.ToString(), *Description.ToString())); + } + else if (Category == NAME_Choice) + { + AddOutputRow(Prefix, Description, ChoiceColour, ChoiceColour); + AddTraceLogRow(Category, SourceLineNo, Description.ToString()); + } + else if (Category == NAME_Start) + { + AddOutputRow(Prefix, Description, StartColour, StartColour); + AddTraceLogRow(Category, SourceLineNo, Description.ToString()); + } + else if (Category == NAME_Finish) + { + AddOutputRow(Prefix, Description, FinishColour, FinishColour); + AddTraceLogRow(Category, SourceLineNo, Description.ToString()); + } + else + { + AddTraceLogRow(Category, SourceLineNo, Description.ToString()); + } +} + +FReply FSUDSEditorToolkit::AddVariableClicked() +{ + TSharedRef Window = SNew(SWindow) + .Title(INVTEXT("Add Variable")) + .IsPopupWindow(true) + .SupportsMaximize(false) + .SupportsMinimize(false) + .CreateTitleBar(true) + .SizingRule(ESizingRule::Autosized) + .FocusWhenFirstShown(true) + .MinWidth(400) + .ActivationPolicy(EWindowActivationPolicy::FirstShown); + + bool bConfirmed = false; + ESUDSValueType ValType = ESUDSValueType::Int; + FText VarNameText = FText::FromString("NewVar"); + + Window->SetContent( + SNew(SVerticalBox) + + SVerticalBox::Slot() + .AutoHeight() + .Padding(5) + [ + SNew(SHorizontalBox) + +SHorizontalBox::Slot() + .FillWidth(0.4) + [ + SNew(STextBlock) + .Text(INVTEXT("Name")) + ] + +SHorizontalBox::Slot() + .FillWidth(0.6) + [ + SNew(SEditableTextBox) + .MinDesiredWidth(150) + .SelectAllTextWhenFocused(true) + .Text(VarNameText) + .OnTextCommitted_Lambda([&VarNameText](const FText& NewText, ETextCommit::Type CommitType) + { + VarNameText = NewText; + }) + ] + + ] + + SVerticalBox::Slot() + .AutoHeight() + .Padding(5) + [ + SNew(SHorizontalBox) + +SHorizontalBox::Slot() + .FillWidth(0.4) + [ + SNew(STextBlock) + .Text(INVTEXT("Type")) + ] + +SHorizontalBox::Slot() + .FillWidth(0.6) + [ + SNew(SComboButton) + .OnGetMenuContent_Lambda([&ValType] + { + FMenuBuilder MenuBuilder(true, NULL); + + // NumEnums() - 1 because the last value is the autogen _MAX value + for (int i = 0; i < StaticEnum()->NumEnums() - 1; ++i) + { + ESUDSValueType Val = (ESUDSValueType)StaticEnum()->GetValueByIndex(i); + if (Val != ESUDSValueType::Empty && Val != ESUDSValueType::Variable) + { + const FUIAction Action(FExecuteAction::CreateLambda([Val, &ValType]() + { + ValType = Val; + })); + MenuBuilder.AddMenuEntry(FText::FromString(StaticEnum()->GetNameStringByIndex(i)), + FText(), + FSlateIcon(), + Action); + } + + } + + return MenuBuilder.MakeWidget(); + + }) + .ButtonContent() + [ + SNew(STextBlock) + .Text_Lambda([&ValType] + { + return FText::FromString(StaticEnum()->GetNameStringByValue((int64)ValType)); + }) + ] + ] + ] + + SVerticalBox::Slot() + .AutoHeight() + .Padding(0, 5, 0, 3) + [ + SNew(SHorizontalBox) + +SHorizontalBox::Slot() + .HAlign(HAlign_Right) + .Padding(2) + [ + SNew(SButton) + .Text(INVTEXT("OK")) + .OnClicked_Lambda([&bConfirmed, Window, VarNameText]() + { + if (!VarNameText.IsEmptyOrWhitespace()) + { + bConfirmed = true; + Window->RequestDestroyWindow(); + } + return FReply::Handled(); + }) + ] + +SHorizontalBox::Slot() + .HAlign(HAlign_Right) + .AutoWidth() + .Padding(2) + [ + SNew(SButton) + .Text(INVTEXT("Cancel")) + .OnClicked_Lambda([&bConfirmed, Window]() + { + bConfirmed = false; + Window->RequestDestroyWindow(); + return FReply::Handled(); + }) + ] + + ] + ); + + FVector2D Pos = FSlateApplication::Get().GetCursorPos(); + Pos.X -= 230; + Pos.Y += 100; + Window->MoveWindowTo(Pos); + + // This doesn't return until closed + GEditor->EditorAddModalWindow(Window); + + if (bConfirmed) + { + FName VarName(VarNameText.ToString().TrimStartAndEnd()); + const FSUDSValue Val(ValType); + FName GlobalVarName; + if (USUDSLibrary::IsDialogueVariableGlobal(VarName, GlobalVarName)) + { + USUDSSubsystem::Test_DummyGlobalVariables.Add(GlobalVarName, Val); + OnDialogueUserEditedVar(nullptr, VarName, Val); + } + else + { + ManualOverrideVariables.Add(VarName, Val); + if (Dialogue) + { + // This will cause a refresh + Dialogue->SetVariable(VarName, Val); + } + else + { + // This won't get called if dialogue is not initialised, so call it to get log & refresh + OnDialogueUserEditedVar(nullptr, VarName, Val); + } + } + + } + return FReply::Handled(); + +} + + + +void FSUDSEditorToolkit::UpdateVariables() +{ + VariableRows.Empty(); + // From dialogue (will contain manual overrides already) + if (Dialogue) + { + for (auto& Pair : Dialogue->GetVariables()) + { + VariableRows.Add(MakeShareable( + new FSUDSEditorVariableRow(Pair.Key, Pair.Value, ManualOverrideVariables.Contains(Pair.Key)))); + } + } + else + { + // Manual overrides can occur before dialogue is started + // Otherwise, they will be in the dialogue + for (auto& Pair : ManualOverrideVariables) + { + VariableRows.Add(MakeShareable(new FSUDSEditorVariableRow(Pair.Key, Pair.Value, true))); + } + } + // Also do global vars + for (auto& Pair : USUDSSubsystem::Test_DummyGlobalVariables) + { + VariableRows.Add(MakeShareable( + new FSUDSEditorVariableRow( + // We need to insert the "global." prefix back in since it's removed in the global var list + FName(FString::Printf(TEXT("global.%s"), *Pair.Key.ToString())), + Pair.Value, + true))); + } + + VariableRows.Sort(); + + if (VariablesListView.IsValid()) + { + VariablesListView->RequestListRefresh(); + } + +} + + +void FSUDSEditorToolkit::OnPostReimport(UObject* Object, bool bSuccess) +{ + if (Object == Script) + { + // Destroy any dialogue instance, will not be valid post-import + DestroyDialogue(); + Clear(); + } +} + +void FSUDSEditorToolkit::OnDialogueSetVar(USUDSDialogue* D, + FName VariableName, + const FSUDSValue& ToValue, + const FString& ExpressionStr, + int LineNo) +{ + FText Description; + if (ExpressionStr.Len() > 0) + { + Description = FText::Format(INVTEXT("{0} = {1} = {2}"), + FText::FromName(VariableName), + FText::FromString(ExpressionStr), + FText::FromString(ToValue.ToString())); + } + else + { + Description = FText::Format(INVTEXT("{0} = {1}"), + FText::FromName(VariableName), + FText::FromString(ToValue.ToString())); + + } + AddDialogueStep(NAME_VariableSet, + LineNo, + Description, + INVTEXT("Set Variable")); + UpdateVariables(); +} + +void FSUDSEditorToolkit::OnDialogueUserEditedVar(USUDSDialogue* D, FName VariableName, const FSUDSValue& ToValue) +{ + // User-edited value + const FText Description = FText::Format(INVTEXT("{0} = {1}"), + FText::FromName(VariableName), + FText::FromString(ToValue.ToString())); + AddDialogueStep(NAME_VariableEdit, + 0, + Description, + INVTEXT("Edit Variable")); + UpdateVariables(); + +} + +void FSUDSEditorToolkit::OnDialogueSelectEval(USUDSDialogue* D, + const FString& ExpressionStr, + bool bSuccess, + int LineNo) +{ + AddDialogueStep(NAME_SelectEval, + LineNo, + FText::Format(INVTEXT("{0} = {1}"), + FText::FromString(ExpressionStr), + bSuccess ? INVTEXT("true") : INVTEXT("false")), + INVTEXT("Conditional")); +} + +void FSUDSEditorToolkit::OnDialogueRandomEval(USUDSDialogue* D, + const int RandomOutcome, + int LineNo) +{ + AddDialogueStep(NAME_RandomEval, + LineNo, + FText::Format(INVTEXT(": {0}"), + RandomOutcome), + INVTEXT("Random")); +} + +TSharedRef FSUDSEditorToolkit::OnGenerateRowForOutput( + TSharedPtr Row, + const TSharedRef& OwnerTable) +{ + return SNew( SSUDSEditorOutputItem, OwnerTable ) + .Prefix(Row->Prefix) + .PrefixColour(Row->PrefixColour) + .Line(Row->Line) + .LineColour(Row->LineColour) + .BgColour(Row->BgColour) + .InitialWidth( PrefixColumnWidth ); +} + +TSharedRef FSUDSEditorToolkit::OnGenerateRowForVariable(TSharedPtr Row, + const TSharedRef& Table) +{ + + return SNew( SSUDSEditorVariableItem, Table ) + .VariableName(Row->Name) + .VariableValue(Row->Value) + .bIsManualOverride(Row->bIsManualOverride) + .Parent(this) + .InitialWidth( VarColumnWidth ); + + +} + +void FSUDSEditorToolkit::WriteBackTextIDs() +{ + if (FMessageDialog::Open(EAppMsgType::YesNo, + FText::FromString( + "Are you sure you want to write string keys back to this script?")) + == EAppReturnType::Yes) + { + FSUDSMessageLogger::ClearMessages(); + FSUDSMessageLogger Logger; + FSUDSEditorScriptTools::WriteBackTextIDs(Script, Logger); + } + +} + +void FSUDSEditorToolkit::GenerateVOAssets() +{ + if (FMessageDialog::Open(EAppMsgType::YesNo, + FText::FromString( + "Are you sure you want to generate Dialogue Voice / Dialogue Wave assets for this script?")) + == EAppReturnType::Yes) + { + EObjectFlags Flags = RF_Public | RF_Standalone | RF_Transactional; + FSUDSMessageLogger::ClearMessages(); + FSUDSMessageLogger Logger; + FSUDSEditorVoiceOverTools::GenerateAssets(Script, Flags, &Logger); + } + +} + +void SSUDSEditorVariableItem::Construct(const FArguments& InArgs, const TSharedRef& InOwnerTableView) +{ + InitialWidth = InArgs._InitialWidth; + VariableName = InArgs._VariableName; + VariableValue = InArgs._VariableValue; + bIsManualOverride = InArgs._bIsManualOverride; + Parent = InArgs._Parent; + + SMultiColumnTableRow< TSharedPtr< FString > >::Construct( SMultiColumnTableRow< TSharedPtr< FString > >::FArguments(), InOwnerTableView ); +} + +TSharedRef SSUDSEditorVariableItem::GenerateWidgetForColumn(const FName& ColumnName) +{ +#if ENGINE_MAJOR_VERSION == 5 && ENGINE_MINOR_VERSION > 0 + const FSlateFontInfo PropertyFont = FAppStyle::GetFontStyle(TEXT("PropertyWindow.NormalFont")); + const FSlateBrush* BorderBrush = FAppStyle::GetBrush("ToolPanel.GroupBorder"); +#else + const FSlateFontInfo PropertyFont = FEditorStyle::GetFontStyle(TEXT("PropertyWindow.NormalFont")); + const FSlateBrush* BorderBrush = FEditorStyle::GetBrush("ToolPanel.GroupBorder"); +#endif + const FSlateColor NormalBgColour = FSlateColor(FLinearColor::White); + const FSlateColor ManualOverrideBgColour = FSlateColor(FLinearColor(0.9f,0.7f,1,1)); + + if (ColumnName == "NameHeader") + { + const FMargin BoxMargin(0, 2, 0, 1.333f); + return SNew(SBorder) + .BorderImage(BorderBrush) + .BorderBackgroundColor(bIsManualOverride ? ManualOverrideBgColour : NormalBgColour) + .Padding(BoxMargin) + [ + SNew(SHorizontalBox) + + + SHorizontalBox::Slot() + .FillWidth(1.0) + .Padding(10, 5, 5, 5) + .VAlign(VAlign_Center) + [ + SNew(STextBlock) + .Font(PropertyFont) + .Text(FText::FromName(VariableName)) + ] + ]; + + } + else + { + TSharedPtr ValueWidget; + + FMargin InnerPadding = FMargin(5, 2, 5, 2); + switch (VariableValue.GetType()) + { + case ESUDSValueType::Int: + ValueWidget = SNew(SNumericEntryBox) + .Value(VariableValue.GetIntValue()) + .OnValueCommitted_Lambda([this] (const int32 InValue, ETextCommit::Type) + { + Parent->UserEditVariable(VariableName, InValue); + }) + .MinDesiredValueWidth(60) + .Font(PropertyFont); + break; + case ESUDSValueType::Float: + ValueWidget = SNew(SNumericEntryBox) + .Value(VariableValue.GetFloatValue()) + .OnValueCommitted_Lambda([this] (const float InValue, ETextCommit::Type) + { + Parent->UserEditVariable(VariableName, InValue); + }) + .MinDesiredValueWidth(60) + .Font(PropertyFont); + break; + case ESUDSValueType::Boolean: + ValueWidget = SNew(SCheckBox) + .IsChecked(VariableValue.GetBooleanValue() ? ECheckBoxState::Checked : ECheckBoxState::Unchecked) + .OnCheckStateChanged_Lambda([this] (ECheckBoxState NewState) + { + Parent->UserEditVariable(VariableName, NewState == ECheckBoxState::Checked); + }); + InnerPadding.Top = InnerPadding.Bottom = 2; + break; + case ESUDSValueType::Gender: + ValueWidget = SNew(SComboButton) + .OnGetMenuContent(this, &SSUDSEditorVariableItem::GetGenderMenu) + .ButtonContent() + [ + SNew(STextBlock) + .Text(FText::FromString(VariableValue.ToString())) + .Font(PropertyFont) + ]; + InnerPadding.Top = InnerPadding.Bottom = 1; + + break; + case ESUDSValueType::Text: + ValueWidget = SNew(SEditableTextBox) + .IsReadOnly(false) + .Text(VariableValue.GetTextValue()) + .Font(PropertyFont) + .OnTextCommitted_Lambda([this] (const FText& InValue, ETextCommit::Type) + { + Parent->UserEditVariable(VariableName, InValue); + }); + break; + case ESUDSValueType::Name: + ValueWidget = SNew(SEditableTextBox) + .IsReadOnly(false) + .Text(FText::FromString(VariableValue.GetNameValue().ToString())) + .Font(PropertyFont) + .OnTextCommitted_Lambda([this] (const FText& InValue, ETextCommit::Type) + { + Parent->UserEditVariable(VariableName, FSUDSValue(FName(InValue.ToString()), false)); + }); + break; + case ESUDSValueType::Variable: + case ESUDSValueType::Empty: + default: + ValueWidget = SNew(STextBlock); + break; + + }; + + const FMargin BoxMargin(2, 1, 0, 1); + return SNew(SBox) + .HeightOverride(26) + .Padding(BoxMargin) + [ + SNew(SBorder) + .BorderImage(BorderBrush) + .BorderBackgroundColor(bIsManualOverride ? ManualOverrideBgColour : NormalBgColour) + .VAlign(VAlign_Center) + [ + + SNew(SHorizontalBox) + + SHorizontalBox::Slot() + .Padding(InnerPadding) + .AutoWidth() + [ + ValueWidget.ToSharedRef() + ] + + SHorizontalBox::Slot() + .Padding(5,2,5,2) + .FillWidth(1.0) + .HAlign(HAlign_Right) + [ + SNew(SButton) + .ButtonStyle( FAppStyle::Get(), "SimpleButton" ) + .OnClicked_Lambda([this] + { + // Delete click + Parent->DeleteVariable(VariableName); + return FReply::Handled(); + } ) + .ContentPadding(0) + .IsFocusable(false) + [ + SNew( SImage ) +#if ENGINE_MAJOR_VERSION == 5 && ENGINE_MINOR_VERSION > 0 + .Image( FAppStyle::GetBrush("Icons.Delete") ) +#else + .Image( FEditorStyle::GetBrush("Icons.Delete") ) +#endif + .ColorAndOpacity( FSlateColor::UseForeground() ) + ] + + ] + ] + ]; + + + } + +} + +TSharedRef SSUDSEditorVariableItem::GetGenderMenu() +{ + FMenuBuilder MenuBuilder(true, NULL); + +#if ENGINE_MAJOR_VERSION ==5 && ENGINE_MINOR_VERSION >= 8 + // StaticEnum() doesn't exist anymore so we have to do this manually + const FUIAction Action0(FExecuteAction::CreateSP(this, &SSUDSEditorVariableItem::OnGenderSelected, ETextGender::Feminine)); + MenuBuilder.AddMenuEntry(FText::FromString(LexToString(ETextGender::Feminine)), + FText(), + FSlateIcon(), + Action0); + const FUIAction Action1(FExecuteAction::CreateSP(this, &SSUDSEditorVariableItem::OnGenderSelected, ETextGender::Masculine)); + MenuBuilder.AddMenuEntry(FText::FromString(LexToString(ETextGender::Masculine)), + FText(), + FSlateIcon(), + Action1); + const FUIAction Action2(FExecuteAction::CreateSP(this, &SSUDSEditorVariableItem::OnGenderSelected, ETextGender::Neuter)); + MenuBuilder.AddMenuEntry(FText::FromString(LexToString(ETextGender::Neuter)), + FText(), + FSlateIcon(), + Action2); +#else + // NumEnums() - 1 because the last value is the autogen _MAX value + for (int i = 0; i < StaticEnum()->NumEnums() - 1; ++i) + { + ETextGender Val = (ETextGender)StaticEnum()->GetValueByIndex(i); + const FUIAction Action(FExecuteAction::CreateSP(this, &SSUDSEditorVariableItem::OnGenderSelected, Val)); + MenuBuilder.AddMenuEntry(FText::FromString(StaticEnum()->GetNameStringByIndex(i)), + FText(), + FSlateIcon(), + Action); + + } +#endif + + return MenuBuilder.MakeWidget(); + +} + +void SSUDSEditorVariableItem::OnGenderSelected(ETextGender NewGender) +{ + Parent->UserEditVariable(VariableName, NewGender); +} + +FVector2D SSUDSEditorVariableItem::ComputeDesiredSize(float X) const +{ + // ItemHeight(28) at table level is totally not working for some reason, so fudge it here + FVector2D Desired = SMultiColumnTableRow>::ComputeDesiredSize(X); + Desired.Y = 28; + return Desired; +} + +void SSUDSEditorOutputItem::Construct(const FArguments& InArgs, const TSharedRef& InOwnerTableView) +{ + InitialWidth = InArgs._InitialWidth; + Prefix = InArgs._Prefix; + PrefixColour = InArgs._PrefixColour; + Line = InArgs._Line; + LineColour = InArgs._LineColour; + + SetBorderBackgroundColor(InArgs._BgColour); + + SMultiColumnTableRow< TSharedPtr< FString > >::Construct( SMultiColumnTableRow< TSharedPtr< FString > >::FArguments(), InOwnerTableView ); +} + +TSharedRef SSUDSEditorOutputItem::GenerateWidgetForColumn(const FName& ColumnName) +{ + if (ColumnName == "PrefixHeader") + { + return SNew(SHorizontalBox) + + + SHorizontalBox::Slot() + .FillWidth(1.0) + .Padding(10, 5, 10, 5) + [ + SNew(STextBlock) + .Text(Prefix) + .ColorAndOpacity(PrefixColour) + ]; + } + else + { + return SNew(SHorizontalBox) + + + SHorizontalBox::Slot() + .FillWidth(1.0) + .Padding(10, 5, 10, 5) + [ + SNew(STextBlock) + .Text(Line) + .AutoWrapText(true) + .ColorAndOpacity(LineColour) + ]; + } + + +} + +FSUDSTraceLogMarshaller::FSUDSTraceLogMarshaller() +{ +} + +void FSUDSTraceLogMarshaller::SetText(const FString& SourceString, FTextLayout& TargetTextLayout) +{ + + static const FName LogNormalStyle(TEXT("Log.Normal")); +#if ENGINE_MAJOR_VERSION == 5 && ENGINE_MINOR_VERSION > 0 + const FTextBlockStyle& OrigStyle = FAppStyle::Get().GetWidgetStyle(LogNormalStyle); +#else + const FTextBlockStyle& OrigStyle = FEditorStyle::Get().GetWidgetStyle(LogNormalStyle); +#endif + + for (const auto& Msg : Messages) + { + // Get base style & copy + FTextBlockStyle Style = OrigStyle; + Style.ColorAndOpacity = Msg->Colour; + + TArray> Runs; + Runs.Add(FSlateTextRun::Create(FRunInfo(), Msg->Message, Style)); + + TargetTextLayout.AddLine(FTextLayout::FNewLineData(Msg->Message, Runs)); + } + +} + +void FSUDSTraceLogMarshaller::GetText(FString& TargetString, const FTextLayout& SourceTextLayout) +{ + SourceTextLayout.GetAsText(TargetString); +} + +void FSUDSTraceLogMarshaller::AppendMessage(FName InCategory, int LineNo, const FString& Message, const FSlateColor& Colour) +{ + const int MinCategorySize = 12; + const FString CatStr = InCategory.ToString(); + int CatLen = CatStr.Len(); + const FString CatPadding = CatLen < MinCategorySize ? FString::ChrN(MinCategorySize - CatLen, ' ') : ""; + const FString LineNoStr = LineNo > 0 ? FString::Printf(TEXT("L%04d"), LineNo) : " "; + const FString ConcatLine = FString::Printf(TEXT("%s[%s] %s %s"), *CatPadding, *CatStr, *LineNoStr, *Message); + Messages.Add(MakeShareable(new FSUDSTraceLogMessage(InCategory, ConcatLine, Colour))); + MakeDirty(); +} + +void FSUDSTraceLogMarshaller::ClearMessages() +{ + Messages.Empty(); + MakeDirty(); +} + +void SSUDSTraceLog::Construct(const FArguments& InArgs) +{ + TraceLogMarshaller = MakeShareable(new FSUDSTraceLogMarshaller()); + TraceLogTextBox = SNew(SMultiLineEditableTextBox) +#if ENGINE_MAJOR_VERSION == 5 && ENGINE_MINOR_VERSION > 0 + .Style(FAppStyle::Get(), "Log.TextBox") +#else + .Style(FEditorStyle::Get(), "Log.TextBox") + .TextStyle(FEditorStyle::Get(), "Log.Normal") +#endif + .Marshaller(TraceLogMarshaller) + .IsReadOnly(true) + .AutoWrapText(true) + .OnVScrollBarUserScrolled(this, &SSUDSTraceLog::OnUserScrolled) + .AlwaysShowScrollbars(true); + + ChildSlot + .Padding(3) + [ + SNew(SVerticalBox) + + // could add a filter here like output log + + // log area + +SVerticalBox::Slot() + .FillHeight(1) + [ + TraceLogTextBox.ToSharedRef() + ] + + ]; + + +} + +void SSUDSTraceLog::OnUserScrolled(float X) +{ + // revert to auto scroll when near the bottom + bIsUserScrolled = X < (1.0 - SMALL_NUMBER); +} + +void SSUDSTraceLog::Tick(const FGeometry& AllottedGeometry, const double InCurrentTime, const float InDeltaTime) +{ + SCompoundWidget::Tick(AllottedGeometry, InCurrentTime, InDeltaTime); + + // We seem to have to do this in Tick(), trying to do it after appending never works + if (!bIsUserScrolled) + { + ScrollToEnd(); + } +} + +void SSUDSTraceLog::AppendMessage(FName InCategory, int LineNo, const FString& Message, const FSlateColor& Colour) +{ + TraceLogMarshaller->AppendMessage(InCategory, LineNo, Message, Colour); +} + +void SSUDSTraceLog::ClearMessages() +{ + TraceLogMarshaller->ClearMessages(); +} + +void SSUDSTraceLog::ScrollToEnd() +{ + TraceLogTextBox->ScrollTo(ETextLocation::EndOfDocument); + bIsUserScrolled = false; +} diff --git a/Plugins/SUDS/Source/SUDSEditor/Private/SUDSEditorToolkit.h b/Plugins/SUDS/Source/SUDSEditor/Private/SUDSEditorToolkit.h new file mode 100644 index 00000000..4a22e545 --- /dev/null +++ b/Plugins/SUDS/Source/SUDSEditor/Private/SUDSEditorToolkit.h @@ -0,0 +1,343 @@ +// Copyright Steve Streeting 2022 +// Released under the MIT license https://opensource.org/license/MIT/ +#pragma once + +#include "CoreMinimal.h" +#include "SUDSValue.h" +#include "Framework/Commands/Commands.h" +#include "Framework/Text/BaseTextLayoutMarshaller.h" +#include "Toolkits/AssetEditorToolkit.h" +#include "UObject/Object.h" +#include "Widgets/Views/STableRow.h" + +class SMultiLineEditableTextBox; +struct FSUDSValue; +class USUDSDialogue; +class USUDSScript; + +#define LOCTEXT_NAMESPACE "SUDS" + +class FSUDSToolbarCommands + : public TCommands +{ +public: + + FSUDSToolbarCommands() + : TCommands( + "SUDS", + LOCTEXT("SUDS", "Steves Unreal Dialogue System"), + NAME_None, +#if ENGINE_MAJOR_VERSION == 5 && ENGINE_MINOR_VERSION > 0 + FAppStyle::GetAppStyleSetName() +#else + FEditorStyle::GetStyleSetName() +#endif + ) + { } + +public: + + // TCommands interface + + virtual void RegisterCommands() override + { + UI_COMMAND(StartDialogue, "Start Dialogue", "Start/restart dialogue", EUserInterfaceActionType::Button, FInputChord()); + UI_COMMAND(WriteBackTextIDs, "Write String Keys", "Write string keys back to script source to stabilise for localisation / voice asset links", EUserInterfaceActionType::Button, FInputChord()); + UI_COMMAND(GenerateVOAssets, "Generate Voice Assets", "Generate DialogueVoice and DialogueWave assets for VO", EUserInterfaceActionType::Button, FInputChord()); + } + +public: + + TSharedPtr StartDialogue; + TSharedPtr WriteBackTextIDs; + TSharedPtr GenerateVOAssets; +}; + + +class FSUDSEditorOutputRow +{ +public: + FText Prefix; + FText Line; + FSlateColor PrefixColour; + FSlateColor LineColour; + FSlateColor BgColour; + + FSUDSEditorOutputRow(const FText& InPrefix, + const FText& InLine, + const FSlateColor& InPrefixColour, + const FSlateColor& InLineColour, + const FSlateColor& InBgColour) : + Prefix(InPrefix), + Line(InLine), + PrefixColour(InPrefixColour), + LineColour(InLineColour), + BgColour(InBgColour) + { + } + + +}; + +class SSUDSEditorOutputItem : public SMultiColumnTableRow< TSharedPtr > +{ +public: + SLATE_BEGIN_ARGS(SSUDSEditorOutputItem) + {} + SLATE_ARGUMENT(float, InitialWidth) + SLATE_ARGUMENT(FText, Prefix) + SLATE_ARGUMENT(FText, Line) + SLATE_ARGUMENT(FSlateColor, PrefixColour) + SLATE_ARGUMENT(FSlateColor, LineColour) + SLATE_ARGUMENT(FSlateColor, BgColour) + +SLATE_END_ARGS() + +public: + + /** + * Construct this widget. Called by the SNew() Slate macro. + * + * @param InArgs Declaration used by the SNew() macro to construct this widget. + * @oaram InOwnerTableView The owner table into which this row is being placed. + */ + void Construct( const FArguments& InArgs, const TSharedRef& InOwnerTableView ); + + /** + * Generates the widget for the specified column. + * + * @param ColumnName The name of the column to generate the widget for. + * @return The widget. + */ + virtual TSharedRef GenerateWidgetForColumn( const FName& ColumnName ) override; +protected: + float InitialWidth = 70; + FText Prefix; + FText Line; + FSlateColor PrefixColour; + FSlateColor LineColour; +}; + + +class FSUDSEditorVariableRow +{ +public: + FName Name; + FSUDSValue Value; + bool bIsManualOverride; + + FSUDSEditorVariableRow(const FName& InName, const FSUDSValue& InValue, bool bIsManual) : Name(InName), + Value(InValue), + bIsManualOverride(bIsManual) + { + } + + friend bool operator<(const FSUDSEditorVariableRow& Lhs, const FSUDSEditorVariableRow& RHS) + { + return Lhs.Name.LexicalLess(RHS.Name); + } + + friend bool operator<(const TSharedPtr& Lhs, const TSharedPtr& RHS) + { + return *Lhs < *RHS; + } + + + +}; + +class SSUDSEditorVariableItem : public SMultiColumnTableRow< TSharedPtr > +{ +public: + SLATE_BEGIN_ARGS(SSUDSEditorVariableItem) + {} + SLATE_ARGUMENT(float, InitialWidth) + SLATE_ARGUMENT(FName, VariableName) + SLATE_ARGUMENT(FSUDSValue, VariableValue) + SLATE_ARGUMENT(bool, bIsManualOverride) + SLATE_ARGUMENT(class FSUDSEditorToolkit*, Parent) +SLATE_END_ARGS() + +public: + + /** + * Construct this widget. Called by the SNew() Slate macro. + * + * @param InArgs Declaration used by the SNew() macro to construct this widget. + * @oaram InOwnerTableView The owner table into which this row is being placed. + */ + void Construct( const FArguments& InArgs, const TSharedRef& InOwnerTableView ); + +public: + /** + * Generates the widget for the specified column. + * + * @param ColumnName The name of the column to generate the widget for. + * @return The widget. + */ + virtual TSharedRef GenerateWidgetForColumn( const FName& ColumnName ) override; +protected: + float InitialWidth = 70; + FName VariableName; + FSUDSValue VariableValue; + bool bIsManualOverride = false; + class FSUDSEditorToolkit* Parent = nullptr; + + TSharedRef GetGenderMenu(); + void OnGenderSelected(ETextGender TextGender); + virtual FVector2D ComputeDesiredSize(float) const override; + +}; + +struct FSUDSTraceLogMessage +{ + TSharedRef Message; + FName Category; + FSlateColor Colour; + + FSUDSTraceLogMessage(FName InCategory, const FString& InMessage, const FSlateColor& InColour) + : Message(MakeShared(InMessage)) + , Category(InCategory) + , Colour(InColour) + { + } +}; + + +class FSUDSTraceLogMarshaller : public FBaseTextLayoutMarshaller +{ +public: + + FSUDSTraceLogMarshaller(); + + // ITextLayoutMarshaller + virtual void SetText(const FString& SourceString, FTextLayout& TargetTextLayout) override; + virtual void GetText(FString& TargetString, const FTextLayout& SourceTextLayout) override; + + void AppendMessage(FName InCategory, int LineNo, const FString& Message, const FSlateColor& Colour); + void ClearMessages(); +protected: + TArray< TSharedPtr > Messages; +}; + +// Trace log widget, really just so we can tick and automatically scroll to the end +class SSUDSTraceLog : public SCompoundWidget +{ +public: + SLATE_BEGIN_ARGS(SSUDSTraceLog) + {} + SLATE_END_ARGS() + + SSUDSTraceLog() : bIsUserScrolled(false) {} + + void Construct(const FArguments& InArgs); + virtual void Tick(const FGeometry& AllottedGeometry, const double InCurrentTime, const float InDeltaTime) override; + + void AppendMessage(FName InCategory, int LineNo, const FString& Message, const FSlateColor& Colour); + void ClearMessages(); + void ScrollToEnd(); + +protected: + bool bIsUserScrolled; + TSharedPtr TraceLogMarshaller; + TSharedPtr TraceLogTextBox; + + void OnUserScrolled(float X); + +}; + +class FSUDSEditorToolkit : public FAssetEditorToolkit +{ +public: + void InitEditor(const TArray& InObjects); + + virtual void RegisterTabSpawners(const TSharedRef& InTabManager) override; + virtual void UnregisterTabSpawners(const TSharedRef& InTabManager) override; + + virtual FText GetBaseToolkitName() const override; + virtual FName GetToolkitFName() const override; + virtual FLinearColor GetWorldCentricTabColorScale() const override; + virtual FString GetWorldCentricTabPrefix() const override; + void UserEditVariable(const FName& Name, FSUDSValue Value); + void DeleteVariable(const FName& Name); + +protected: + virtual void OnClose() override; + +private: + USUDSScript* Script = nullptr; + USUDSDialogue* Dialogue = nullptr; + float VarColumnWidth = 120; + float PrefixColumnWidth = 100; + FName StartLabel = NAME_None; + bool bResetVarsOnStart = true; + FDelegateHandle ReimportDelegateHandle; + // FSUDSEditorDialogueRow needs to held by a TSharedPtr for SListView + TSharedPtr>> OutputListView; + TArray> OutputRows; + TSharedPtr ChoicesBox; + TSharedPtr>> VariablesListView; + TArray> VariableRows; + TSharedPtr TraceLog; + TMap ManualOverrideVariables; + + static FName DialogueOutputTabName; + static FName DetailsTabName; + static FName VariablesTabName; + static FName LogTabName; + + + const FSlateColor SpeakerColour = FLinearColor(1.0f, 1.0f, 0.6f, 1.0f); + const FSlateColor ChoiceColour = FLinearColor(0.4f, 1.0f, 0.4f, 1.0f); + const FSlateColor EventColour = FLinearColor(0.2f, 0.6f, 1.0f, 1.0f); + const FSlateColor VarSetColour = FLinearColor(0.8f, 0.6f, 0.9f, 1.0f); + const FSlateColor VarEditColour = FLinearColor(0.6f, 0.3f, 1.0f, 1.0f); + const FSlateColor StartColour = FLinearColor(1.0f, 0.5f, 0.0f, 1.0f); + const FSlateColor FinishColour = FLinearColor(1.0f, 0.3f, 0.3f, 1.0f); + const FSlateColor SelectColour = FLinearColor(1.0f, 0.0f, 0.5f, 1.0f); + const FSlateColor RowBgColour1 = FLinearColor(0.15f, 0.15f, 0.15f, 1.0f); + const FSlateColor RowBgColour2 = FLinearColor(0.3f, 0.3f, 0.3f, 1.0f); + + void ExtendToolbar(FToolBarBuilder& ToolbarBuilder, TWeakPtr Tab); + TSharedRef GetStartLabelMenu(); + FText GetSelectedStartLabel() const; + void OnStartLabelSelected(FName Label); + ECheckBoxState GetResetVarsCheckState() const; + void OnResetVarsCheckStateChanged(ECheckBoxState NewState); + FReply AddVariableClicked(); + void UpdateVariables(); + void EnsureTabsVisible(); + void StartDialogue(); + void DestroyDialogue(); + void UpdateOutput(); + void UpdateChoiceButtons(); + void AddOutputRow(const FText& Prefix, const FText& Line, const FSlateColor& PrefixColour, const FSlateColor& LineColour); + void AddTraceLogRow(const FName& Category, int SourceLineNo, const FString& Message); + + void AddDialogueStep(const FName& Category, int SourceLineNo, const FText& Description, const FText& Prefix); + + void OnDialogueChoice(USUDSDialogue* Dialogue, int ChoiceIndex, int LineNo); + void OnDialogueEvent(USUDSDialogue* Dialogue, FName EventName, const TArray& Args, int LineNo); + void OnDialogueFinished(USUDSDialogue* Dialogue); + void OnDialogueProceeding(USUDSDialogue* Dialogue); + void OnDialogueStarting(USUDSDialogue* Dialogue, FName LabelName); + void OnDialogueSpeakerLine(USUDSDialogue* Dialogue, int LineNo); + void OnDialogueSetVar(USUDSDialogue* Dialogue, FName VariableName, const FSUDSValue& ToValue, const FString& ExpressionStr, int LineNo); + void OnDialogueUserEditedVar(USUDSDialogue* Dialogue, FName VariableName, const FSUDSValue& ToValue); + void OnDialogueSelectEval(USUDSDialogue* Dialogue, const FString& ExpressionStr, bool bSuccess, int LineNo); + void OnDialogueRandomEval(USUDSDialogue* Dialogue, const int RandomOutcome, int LineNo); + + TSharedRef OnGenerateRowForOutput( + TSharedPtr FsudsEditorDialogueRow, + const TSharedRef& TableViewBase); + TSharedRef OnGenerateRowForVariable(TSharedPtr Row, + const TSharedRef& Table); + FSlateColor GetColourForCategory(const FName& Category); + void OnPostReimport(UObject* Object, bool bSuccess); + void Clear(); + void WriteBackTextIDs(); + void GenerateVOAssets(); + +}; + +#undef LOCTEXT_NAMESPACE \ No newline at end of file diff --git a/Plugins/SUDS/Source/SUDSEditor/Private/SUDSEditorVoiceOverTools.cpp b/Plugins/SUDS/Source/SUDSEditor/Private/SUDSEditorVoiceOverTools.cpp new file mode 100644 index 00000000..99728c37 --- /dev/null +++ b/Plugins/SUDS/Source/SUDSEditor/Private/SUDSEditorVoiceOverTools.cpp @@ -0,0 +1,381 @@ +// Copyright Steve Streeting 2022 +// Released under the MIT license https://opensource.org/license/MIT/ +#include "SUDSEditorVoiceOverTools.h" + +#include "ObjectTools.h" +#include "PackageTools.h" +#include "SUDSEditorSettings.h" +#include "SUDSMessageLogger.h" +#include "SUDSScript.h" +#include "SUDSScriptNodeText.h" +#include "AssetRegistry/AssetRegistryModule.h" +#include "Internationalization/Regex.h" +#include "Sound/DialogueVoice.h" +#include "Sound/DialogueWave.h" +#include "Sound/SoundWave.h" + + +void FSUDSEditorVoiceOverTools::GenerateAssets(USUDSScript* Script, EObjectFlags Flags, FSUDSMessageLogger* Logger) +{ + // First check for problems + if (auto Settings = GetDefault()) + { + bool bError = false; + if ((Settings->DialogueVoiceAssetLocation == ESUDSAssetLocation::SharedDirectory || Settings-> + DialogueVoiceAssetLocation == ESUDSAssetLocation::SharedDirectorySubdir) && + (Settings->DialogueVoiceAssetSharedDir.Path.IsEmpty() || + !FPackageName::IsValidPath(Settings->DialogueVoiceAssetSharedDir.Path))) + { + Logger->Logf(ELogVerbosity::Error, + TEXT( + "Dialogue Voice assets are set to generate to a shared dir, but the dir '%s' is not valid."), + *Settings->DialogueVoiceAssetSharedDir.Path); + bError = true; + } + if ((Settings->DialogueWaveAssetLocation == ESUDSAssetLocation::SharedDirectory || Settings-> + DialogueWaveAssetLocation == ESUDSAssetLocation::SharedDirectorySubdir) && + (Settings->DialogueWaveAssetSharedDir.Path.IsEmpty() || + !FPackageName::IsValidPath(Settings->DialogueWaveAssetSharedDir.Path))) + { + Logger->Logf(ELogVerbosity::Error, + TEXT( + "Dialogue Wave assets are set to generate to a shared dir, but the dir '%s' is not valid."), + *Settings->DialogueWaveAssetSharedDir.Path); + bError = true; + } + if (bError) { return; } + } + else + { + Logger->Logf(ELogVerbosity::Error, TEXT("Unable to generate voice assets, settings not found")); + return; + } + + TMap UnsavedVoices; + GenerateVoiceAssets(Script, Flags, Logger, UnsavedVoices); + GenerateWaveAssets(Script, Flags, UnsavedVoices, Logger); +} + +void FSUDSEditorVoiceOverTools::GenerateVoiceAssets(USUDSScript* Script, EObjectFlags Flags, FSUDSMessageLogger* Logger, TMap &OutCreatedVoices) +{ + auto Registry = &FModuleManager::LoadModuleChecked(AssetRegistryConstants::ModuleName).Get(); + + FString Prefix; + FString ParentDir; + if (auto Settings = GetDefault()) + { + Prefix = Settings->DialogueVoiceAssetPrefix; + ParentDir = GetVoiceOutputDir(Script); + } + else + { + Logger->Logf(ELogVerbosity::Error, TEXT("Unable to generate voice assets, settings not found")); + return; + } + + for (auto Speaker : Script->GetSpeakers()) + { + // All Dialogue Voice assets will be created in their own package + FString PackageName; + FString AssetName; + if (GetSpeakerVoiceAssetNames(Script, Speaker, PackageName, AssetName)) + { + if (FPackageName::DoesPackageExist(*PackageName)) + { + // Package already exists, so try and import over the top of it, if it doesn't already have a source file path + TArray Assets; + if (Registry->GetAssetsByPackageName(*PackageName, Assets)) + { + if (Assets.Num() > 0) + { + if (!Assets[0].GetAsset()->IsA(UDialogueVoice::StaticClass())) + { + Logger->Logf(ELogVerbosity::Error, + TEXT( + "Asset %s already exists but is not a Dialogue Voice! Cannot replace, please move aside and re-import script."), + *PackageName); + } + else + { + // Make sure we still link the existing voice + Script->SetSpeakerVoice(Speaker, Cast(Assets[0].GetAsset())); + Script->MarkPackageDirty(); + } + // Either way nothing more to do + continue; + } + } + } + + + // If we got here then Dialogue Voice didn't exist (although package might have) + // It's safe to call CreatePackage either way, it'll return the existing one if needed + UPackage* Package = CreatePackage(*PackageName); + if (!ensure(Package)) + { + Logger->Logf(ELogVerbosity::Error, + TEXT("Failed to create/retrieve package for voice asset %s"), + *PackageName); + } + else + { + //Logger->Logf(ELogVerbosity::Display, TEXT("Creating voice asset %s"), *PackageName); + + UDialogueVoice* NewVoiceAsset = NewObject(Package, FName(AssetName), Flags); + OutCreatedVoices.Add(Speaker, NewVoiceAsset); + // there's nothing else to create here, voice is mostly a placeholder with the rest set up later by user + FAssetRegistryModule::AssetCreated(NewVoiceAsset); + + Script->SetSpeakerVoice(Speaker, NewVoiceAsset); + Script->MarkPackageDirty(); + + Package->FullyLoad(); + NewVoiceAsset->MarkPackageDirty(); + } + } + } +} + +bool FSUDSEditorVoiceOverTools::GetSpeakerVoicePackageName(USUDSScript* Script, + const FString& SpeakerID, + FString& OutPackageName) +{ + FString NotUsed; + return GetSpeakerVoiceAssetNames(Script, SpeakerID, OutPackageName, NotUsed); +} + +bool FSUDSEditorVoiceOverTools::GetSpeakerVoiceAssetNames(USUDSScript* Script, + const FString& SpeakerID, + FString& OutPackageName, + FString& OutAssetName) +{ + if (auto Settings = GetDefault()) + { + FString Prefix = Settings->DialogueVoiceAssetPrefix; + FString ParentDir = GetVoiceOutputDir(Script); + + OutAssetName = FString::Printf(TEXT("%s%s"), *Prefix, *ObjectTools::SanitizeObjectName(SpeakerID)); + OutPackageName = UPackageTools::SanitizePackageName(ParentDir / OutAssetName); + return true; + } + return false; +} + +UDialogueVoice* FSUDSEditorVoiceOverTools::FindSpeakerVoice(USUDSScript* Script, + const FString& SpeakerID) +{ + FString PackageName; + if (GetSpeakerVoicePackageName(Script, SpeakerID, PackageName)) + { + + auto Registry = &FModuleManager::LoadModuleChecked(AssetRegistryConstants::ModuleName).Get(); + + if (FPackageName::DoesPackageExist(*PackageName)) + { + // Package already exists, so try and import over the top of it, if it doesn't already have a source file path + TArray Assets; + if (Registry->GetAssetsByPackageName(*PackageName, Assets)) + { + if (Assets.Num() > 0) + { + if (Assets[0].GetAsset()->IsA(UDialogueVoice::StaticClass())) + { + return Cast(Assets[0].GetAsset()); + } + } + } + } + } + return nullptr; +} + +void FSUDSEditorVoiceOverTools::GenerateWaveAssets(USUDSScript* Script, EObjectFlags Flags, TMap UnsavedVoices, FSUDSMessageLogger* Logger) +{ + // We need to identify specific lines of dialogue to specify a wave asset to go with them, which means we + // need to use the Text ID, just like we do for translations. This means that really, you shouldn't start to + // assign sounds to wave assets until you've written the text IDs back to the script + auto Registry = &FModuleManager::LoadModuleChecked(AssetRegistryConstants::ModuleName).Get(); + + FString Prefix; + FString ParentDir; + if (auto Settings = GetDefault()) + { + Prefix = FString::Printf(TEXT("%s%s_"), *Settings->DialogueWaveAssetPrefix, *GetScriptNameAsPrefix(Script)); + ParentDir = GetWaveOutputDir(Script); + } + else + { + Logger->Logf(ELogVerbosity::Error, TEXT("Unable to generate wave assets, settings not found")); + return; + } + + for (auto Node : Script->GetNodes()) + { + if (auto Line = Cast(Node)) + { + // We use the TextID to identify a specific line, just like for translation + FString TextID = Line->GetTextID(); + + // Remove the '@' characters from the text ID for creating the asset name, they just turn into excessive '_'s + const FRegexPattern TextIDPattern(TEXT("\\@([0-9a-fA-F]+)\\@")); + FRegexMatcher TextIDRegex(TextIDPattern, TextID); + if (TextIDRegex.FindNext()) + { + TextID = TextIDRegex.GetCaptureGroup(1); + } + + // All Dialogue Voice assets will be created in their own package + const FString SanitizedName = FString::Printf(TEXT("%s%s"), + *Prefix, + *ObjectTools::SanitizeObjectName(TextID)); + const FString PackageName = UPackageTools::SanitizePackageName(ParentDir / SanitizedName); + + UDialogueWave* WaveAsset = nullptr; + if (FPackageName::DoesPackageExist(*PackageName)) + { + // Package already exists, so try and import over the top of it, if it doesn't already have a source file path + TArray Assets; + if (Registry->GetAssetsByPackageName(*PackageName, Assets)) + { + if (Assets.Num() > 0) + { + if (!Assets[0].GetAsset()->IsA(UDialogueWave::StaticClass())) + { + Logger->Logf(ELogVerbosity::Error, + TEXT( + "Asset %s already exists but is not a Dialogue Wave! Cannot replace, please move aside and re-import script."), + *PackageName); + continue; + } + else + { + WaveAsset = Cast(Assets[0].GetAsset()); + } + } + } + } + + UDialogueVoice* SpeakerVoice = nullptr; + // Finding speaker assets via FAssetRegistryModule does not work on unsaved assets, so we need to check + // the unsaved voices that have been created in the previous step first + if (auto pSV = UnsavedVoices.Find(Line->GetSpeakerID())) + { + SpeakerVoice = *pSV; + } + if (!SpeakerVoice) + { + // Now try assets + SpeakerVoice = FindSpeakerVoice(Script, Line->GetSpeakerID()); + } + if (!SpeakerVoice) + { + FString SpeakerPackageName; + GetSpeakerVoicePackageName(Script, Line->GetSpeakerID(), SpeakerPackageName); + Logger->Logf(ELogVerbosity::Error, TEXT("Error: speaker voice asset for %s is missing, expected to be at %s"), *Line->GetSpeakerID(), *SpeakerPackageName); + continue; + } + + UPackage* Package = CreatePackage(*PackageName); + if (!ensure(Package)) + { + Logger->Logf(ELogVerbosity::Error, + TEXT("Failed to create/retrieve package for wave asset %s"), + *PackageName); + } + else + { + FString NativeLangText = Line->GetText().ToString(); + USoundWave* SoundWave = nullptr; + if (!WaveAsset) + { + //Logger->Logf(ELogVerbosity::Display, TEXT("Creating wave asset %s"), *PackageName); + WaveAsset = NewObject(Package, FName(SanitizedName), Flags); + FAssetRegistryModule::AssetCreated(WaveAsset); + } + else + { + // We will always update the existing asset, but will warn if the text is changing + if (WaveAsset->ContextMappings.Num() > 0) + { + auto Mapping = WaveAsset->ContextMappings[0]; + if (IsValid(Mapping.SoundWave)) + { + SoundWave = Mapping.SoundWave; + // This is OK if the context is what we were going to create anyway, we'll just leave it alone + if (Mapping.Context.Speaker != SpeakerVoice || + WaveAsset->SpokenText != NativeLangText) + { + // No good, user has assigned sound to this dialogue wave, but we need to change it to + // a different speaker / line + Logger->Logf(ELogVerbosity::Error, + TEXT( + "Dialogue Wave %s has an assigned Sound Wave but the speaker or text has changed. You should update the sound wave!"), + *PackageName); + } + } + } + } + + + // Set spoken text - native language only. Dialogue Wave handles the localisation of this separately, + // we can't link it to our String Table unfortunately. + WaveAsset->SpokenText = NativeLangText; + // Set dialogue context + // We need a Speaker Voice, we'll leave the sound and targets blank + WaveAsset->UpdateContext(WaveAsset->ContextMappings[0], SoundWave, SpeakerVoice, TArray()); + + // Now assign the wave to the line + Line->SetWave(WaveAsset); + Script->MarkPackageDirty(); + + Package->FullyLoad(); + WaveAsset->MarkPackageDirty(); + } + } + } +} + +FString FSUDSEditorVoiceOverTools::GetVoiceOutputDir(USUDSScript* Script) +{ + if (auto Settings = GetDefault()) + { + const FString PackagePath = FPackageName::GetLongPackagePath(Script->GetOuter()->GetOutermost()->GetPathName()); + const FString ScriptPrefix = GetScriptNameAsPrefix(Script); + return Settings->GetVoiceOutputDir(PackagePath, ScriptPrefix); + } + return FString(); +} + +FString FSUDSEditorVoiceOverTools::GetWaveOutputDir(USUDSScript* Script) +{ + if (auto Settings = GetDefault()) + { + const FString PackagePath = FPackageName::GetLongPackagePath(Script->GetOuter()->GetOutermost()->GetPathName()); + const FString ScriptPrefix = GetScriptNameAsPrefix(Script); + return Settings->GetWaveOutputDir(PackagePath, ScriptPrefix); + } + return FString(); +} + + + +FString FSUDSEditorVoiceOverTools::GetScriptNameAsPrefix(USUDSScript* Script) +{ + FString Name = Script->GetName(); + + if (auto Settings = GetDefault()) + { + if (Settings->StripScriptPrefixesWhenGeneratingNames) + { + int32 Index = INDEX_NONE; + if (Name.FindChar('_', Index)) + { + if (Index < Name.Len() - 1) + { + Name = Name.RightChop(Index + 1); + } + } + } + } + return Name; +} diff --git a/Plugins/SUDS/Source/SUDSEditor/Private/SUDSMessageLogger.cpp b/Plugins/SUDS/Source/SUDSEditor/Private/SUDSMessageLogger.cpp new file mode 100644 index 00000000..c0914ef8 --- /dev/null +++ b/Plugins/SUDS/Source/SUDSEditor/Private/SUDSMessageLogger.cpp @@ -0,0 +1,93 @@ +// Copyright Steve Streeting 2022 +// Released under the MIT license https://opensource.org/license/MIT/ +#include "SUDSMessageLogger.h" +#include "IMessageLogListing.h" +#include "MessageLogModule.h" +#include "Modules/ModuleManager.h" + +FSUDSMessageLogger::~FSUDSMessageLogger() +{ + if (bWriteToMessageLog) + { + //Always clear the old message after an import or re-import + const TCHAR* LogTitle = TEXT("SUDS"); + FMessageLogModule& MessageLogModule = FModuleManager::LoadModuleChecked("MessageLog"); + TSharedPtr LogListing = MessageLogModule.GetLogListing(LogTitle); + LogListing->SetLabel(FText::FromString("SUDS")); + // Should NOT clear messages here, otherwise when FSUDSMessageLogger is used multiple times in the import process, + // each one is clearing messages from previous stages of the import. + //LogListing->ClearMessages(); + + if(ErrorMessages.Num() > 0) + { + LogListing->AddMessages(MoveTemp(ErrorMessages)); + LogListing->NotifyIfAnyMessages(NSLOCTEXT("SUDS", "ImportErrors", "There were issues with the import."), EMessageSeverity::Warning); + MessageLogModule.OpenMessageLog(LogTitle); + } + } +} + +bool FSUDSMessageLogger::HasErrors() const +{ + for (const TSharedRef& Msg : ErrorMessages) + { + if (Msg->GetSeverity() == EMessageSeverity::Error) + { + return true; + } + } + return false; +} + +int FSUDSMessageLogger::NumErrors() const +{ + int Errs = 0; + for (const TSharedRef& Msg : ErrorMessages) + { + if (Msg->GetSeverity() == EMessageSeverity::Error) + { + ++Errs; + } + } + return Errs; +} + +bool FSUDSMessageLogger::HasWarnings() const +{ + for (const TSharedRef& Msg : ErrorMessages) + { + if (Msg->GetSeverity() == EMessageSeverity::Warning) + { + return true; + } + } + return false; +} + +int FSUDSMessageLogger::NumWarnings() const +{ + int Count = 0; + for (const TSharedRef& Msg : ErrorMessages) + { + if (Msg->GetSeverity() == EMessageSeverity::Warning) + { + ++Count; + } + } + return Count; +} + +void FSUDSMessageLogger::AddMessage(EMessageSeverity::Type Severity, const FText& Text) +{ + ErrorMessages.Add(FTokenizedMessage::Create(Severity, Text)); +} + +void FSUDSMessageLogger::ClearMessages() +{ + const TCHAR* LogTitle = TEXT("SUDS"); + FMessageLogModule& MessageLogModule = FModuleManager::LoadModuleChecked("MessageLog"); + TSharedPtr LogListing = MessageLogModule.GetLogListing(LogTitle); + LogListing->SetLabel(FText::FromString("SUDS")); + LogListing->ClearMessages(); +} + diff --git a/Plugins/SUDS/Source/SUDSEditor/Private/SUDSScriptActions.cpp b/Plugins/SUDS/Source/SUDSEditor/Private/SUDSScriptActions.cpp new file mode 100644 index 00000000..c84038ec --- /dev/null +++ b/Plugins/SUDS/Source/SUDSEditor/Private/SUDSScriptActions.cpp @@ -0,0 +1,134 @@ +// Copyright Steve Streeting 2022 +// Released under the MIT license https://opensource.org/license/MIT/ +#include "SUDSScriptActions.h" + +#include "SUDSEditorScriptTools.h" +#include "SUDSEditorSettings.h" +#include "SUDSEditorToolkit.h" +#include "SUDSEditorVoiceOverTools.h" +#include "SUDSMessageLogger.h" +#include "SUDSScript.h" +#include "ToolMenuSection.h" +#include "EditorFramework/AssetImportData.h" +#include "Misc/MessageDialog.h" +#include "Widgets/Views/SListView.h" + +FText FSUDSScriptActions::GetName() const +{ + return INVTEXT("SUDS Script"); +} + +FString FSUDSScriptActions::GetObjectDisplayName(UObject* Object) const +{ + return Object->GetName(); +} + +UClass* FSUDSScriptActions::GetSupportedClass() const +{ + return USUDSScript::StaticClass(); +} + +FColor FSUDSScriptActions::GetTypeColor() const +{ + return FColor::Orange; +} + +uint32 FSUDSScriptActions::GetCategories() +{ + return EAssetTypeCategories::Misc; +} + +void FSUDSScriptActions::GetResolvedSourceFilePaths(const TArray& TypeAssets, + TArray& OutSourceFilePaths) const +{ + for (auto& Asset : TypeAssets) + { + const auto Script = CastChecked(Asset); + if (Script->AssetImportData) + { + Script->AssetImportData->ExtractFilenames(OutSourceFilePaths); + } + } +} + +bool FSUDSScriptActions::HasActions(const TArray& InObjects) const +{ + return true; +} + +void FSUDSScriptActions::OpenAssetEditor(const TArray& InObjects, + TSharedPtr EditWithinLevelEditor) +{ + MakeShared()->InitEditor(InObjects); +} + +void FSUDSScriptActions::GetActions(const TArray& InObjects, FToolMenuSection& Section) +{ + const auto Scripts = GetTypedWeakObjectPtrs(InObjects); + + Section.AddMenuEntry( + "WriteBackTextIDs", + NSLOCTEXT("SUDS", "WriteBackTextIDs", "Write Back String Keys"), + NSLOCTEXT("SUDS", + "WriteBackTextIDsTooltip", + "Write string table keys back to source script to make them constant for localisation."), + FSlateIcon(FAppStyle::GetAppStyleSetName(), "Icons.Details"), + FUIAction( + FExecuteAction::CreateSP(this, &FSUDSScriptActions::WriteBackTextIDs, Scripts), + FCanExecuteAction() + ) + ); + Section.AddMenuEntry( + "GenerateVOAssets", + NSLOCTEXT("SUDS", "GenerateVOAssets", "Generate Voice Assets"), + NSLOCTEXT("SUDS", + "GenerateVOAssetsTooltip", + "Generate Dialogue Voice / Dialogue Wave assets for the selected scripts"), + FSlateIcon(FAppStyle::GetAppStyleSetName(), "Icons.Toolbar.Export"), + FUIAction( + FExecuteAction::CreateSP(this, &FSUDSScriptActions::GenerateVOAssets, Scripts), + FCanExecuteAction() + ) + ); + +} + +void FSUDSScriptActions::WriteBackTextIDs(TArray> Scripts) +{ + if (FMessageDialog::Open(EAppMsgType::YesNo, + FText::FromString( + "Are you sure you want to write string keys back to the selected scripts?")) + == EAppReturnType::Yes) + { + FSUDSMessageLogger::ClearMessages(); + FSUDSMessageLogger Logger; + for (auto Script : Scripts) + { + if (Script.IsValid()) + { + FSUDSEditorScriptTools::WriteBackTextIDs(Script.Get(), Logger); + } + } + } +} + + +void FSUDSScriptActions::GenerateVOAssets(TArray> Scripts) +{ + if (FMessageDialog::Open(EAppMsgType::YesNo, + FText::FromString( + "Are you sure you want to generate Dialogue Voice / Dialogue Wave assets for the selected scripts?")) + == EAppReturnType::Yes) + { + EObjectFlags Flags = RF_Public | RF_Standalone | RF_Transactional; + FSUDSMessageLogger::ClearMessages(); + FSUDSMessageLogger Logger; + for (auto WeakScript : Scripts) + { + if (auto Script = WeakScript.Get()) + { + FSUDSEditorVoiceOverTools::GenerateAssets(Script, Flags, &Logger); + } + } + } +} diff --git a/Plugins/SUDS/Source/SUDSEditor/Private/SUDSScriptFactory.cpp b/Plugins/SUDS/Source/SUDSEditor/Private/SUDSScriptFactory.cpp new file mode 100644 index 00000000..e57660f0 --- /dev/null +++ b/Plugins/SUDS/Source/SUDSEditor/Private/SUDSScriptFactory.cpp @@ -0,0 +1,262 @@ +// Copyright Steve Streeting 2022 +// Released under the MIT license https://opensource.org/license/MIT/ +#include "SUDSScriptFactory.h" + +#include "PackageTools.h" +#include "SUDSEditorSettings.h" +#include "SUDSEditorVoiceOverTools.h" +#include "SUDSMessageLogger.h" +#include "SUDSScript.h" +#include "AssetRegistry/AssetRegistryModule.h" +#include "EditorFramework/AssetImportData.h" +#include "Internationalization/StringTable.h" +#include "Editor.h" +#include "ObjectTools.h" +#include "Internationalization/StringTableCore.h" + + +USUDSScriptFactory::USUDSScriptFactory() +{ + SupportedClass = USUDSScript::StaticClass(); + bCreateNew = false; + bEditorImport = true; + bText = true; + Formats.Add(TEXT("sud;SUDS Script File")); +} + +UObject* USUDSScriptFactory::FactoryCreateText(UClass* InClass, + UObject* InParent, + FName InName, + EObjectFlags Flags, + UObject* Context, + const TCHAR* Type, + const TCHAR*& Buffer, + const TCHAR* BufferEnd, + FFeedbackContext* Warn) +{ + Flags |= RF_Transactional; + FSUDSMessageLogger::ClearMessages(); + FSUDSMessageLogger Logger; + + USUDSScript* Result = nullptr; + + GEditor->GetEditorSubsystem()->BroadcastAssetPreImport(this, InClass, InParent, InName, Type); + + const FString FactoryCurrentFilename = UFactory::GetCurrentFilename(); + FString CurrentSourcePath; + FString FilenameNoExtension; + FString UnusedExtension; + FPaths::Split(FactoryCurrentFilename, CurrentSourcePath, FilenameNoExtension, UnusedExtension); + const FString LongPackagePath = FPackageName::GetLongPackagePath(InParent->GetOutermost()->GetPathName()); + + const FString NameForErrors(InName.ToString()); + + // Now parse this using utility + if(Importer.ImportFromBuffer(Buffer, BufferEnd - Buffer, NameForErrors, &Logger, false)) + { + + // Populate with data + Result = NewObject(InParent, InName, Flags); + UStringTable* StringTable = CreateStringTable(InParent, InName, Result, Flags, &Logger); + Importer.PopulateAsset(Result, StringTable); + + // Register source info + const FMD5Hash Hash = FSUDSScriptImporter::CalculateHash(Buffer, BufferEnd - Buffer); + Result->AssetImportData->Update(FactoryCurrentFilename, Hash); + + // VO assets at import time? + if (ShouldGenerateVoiceAssets(LongPackagePath)) + { + FSUDSEditorVoiceOverTools::GenerateAssets(Result, Flags, &Logger); + } + + } + + GEditor->GetEditorSubsystem()->BroadcastAssetPostImport(this, Result); + + return Result; +} + +bool USUDSScriptFactory::ShouldGenerateVoiceAssets(const FString& PackagePath) const +{ + if (auto Settings = GetDefault()) + { + return Settings->ShouldGenerateVoiceAssets(PackagePath); + } + return false; +} + +UStringTable* USUDSScriptFactory::CreateStringTable(UObject* ScriptParent, FName InName, USUDSScript* Script, EObjectFlags Flags, FSUDSMessageLogger* Logger) +{ + auto Settings = GetDefault(); + const bool bCreateSeparatePackage = Settings && Settings->bCreateStringTablesAsSeparatePackages; + + const FName StringTableName = FName(InName.ToString() + "Strings"); + UStringTable* Table = nullptr; + auto Registry = &FModuleManager::LoadModuleChecked(AssetRegistryConstants::ModuleName).Get(); + const FString PackageDir = FPackageName::GetLongPackagePath(Script->GetOuter()->GetOutermost()->GetPathName()); + const FString PackageName = UPackageTools::SanitizePackageName(PackageDir / StringTableName.ToString()); + + if (bCreateSeparatePackage) + { + + // Create string table as its own package (.uasset) + + { + // Destroy any string table packed together with the script + const FString ScriptPackageName = Script->GetPackage()->GetPathName(); + TArray ScriptAssets; + Registry->GetAssetsByPackageName(*ScriptPackageName, ScriptAssets); + TArray StringTableAssets; + for (auto& Asset : ScriptAssets) + { + if (Asset.GetClass() == UStringTable::StaticClass()) + { + StringTableAssets.Add(Asset.GetAsset()); + } + } + if (StringTableAssets.Num() > 0) + { + ObjectTools::ForceDeleteObjects(StringTableAssets, false ); + } + } + + + if (FPackageName::DoesPackageExist(*PackageName)) + { + // Package already exists, so try and import over the top of it, if it doesn't already have a source file path + TArray Assets; + if (Registry->GetAssetsByPackageName(*PackageName, Assets)) + { + if (Assets.Num() > 0) + { + if (Assets[0].GetAsset()->IsA(UStringTable::StaticClass())) + { + Table = Cast(Assets[0].GetAsset()); + Table->GetMutableStringTable()->ClearSourceStrings(); + Table->MarkPackageDirty(); + } + else + { + Logger->Logf(ELogVerbosity::Error, + TEXT( + "Asset %s already exists but is not a String Table! Cannot replace, please move aside and re-import script."), + *PackageName); + return nullptr; + } + } + } + } + + if (!Table) + { + // String Table didn't exist (although package might have) + // It's safe to call CreatePackage either way, it'll return the existing one if needed + UPackage* Package = CreatePackage(*PackageName); + if (!ensure(Package)) + { + Logger->Logf(ELogVerbosity::Error, + TEXT("Failed to create/retrieve package for string table %s"), + *PackageName); + } + else + { + //Logger->Logf(ELogVerbosity::Display, TEXT("Creating string table %s"), *PackageName); + + // This constructor registers the string table with FStringTableRegistry + Table = NewObject(Package, StringTableName, Flags); + Package->FullyLoad(); + Table->MarkPackageDirty(); + FAssetRegistryModule::AssetCreated(Table); + } + } + } + else + { + // Destroy separate strings package if it exists (could flip this option off after importing) + if (FPackageName::DoesPackageExist(*PackageName)) + { + TArray Assets; + if (Registry->GetAssetsByPackageName(*PackageName, Assets)) + { + if (Assets.Num() > 0) + { + // Note: we need to force deletion because old string table will be referenced + // Unfortunately there is no ObjectTools::ForceDeleteAssets, only the inner + // ForceDeleteObjects, so we need to do it ourselves + ForceDeleteAssets(Assets); + + } + } + } + + // Default route, create string table inside script package + Table = NewObject(ScriptParent, StringTableName, Flags); + + } + + return Table; + +} + +void USUDSScriptFactory::ForceDeleteAssets(const TArray& AssetsToDelete) +{ + // Copied from ObjectTools::DeleteAssets, except that we're using ForceDeleteObjects so we + // don't have to display any confusing messages about moving the string table into the shared asset + + TArray> PackageFilesToDelete; + TArray ObjectsToDelete; + for ( int i = 0; i < AssetsToDelete.Num(); i++ ) + { + const FAssetData& AssetData = AssetsToDelete[i]; + UObject *ObjectToDelete = AssetData.GetAsset({ ULevel::LoadAllExternalObjectsTag }); + // Assets can be loaded even when their underlying type/class no longer exists... + if ( ObjectToDelete!=nullptr ) + { + ObjectsToDelete.Add( ObjectToDelete ); + } + else if ( AssetData.IsUAsset() ) + { + // ... In this cases there is no underlying asset or type so remove the package itself directly after confirming it's valid to do so. + FString PackageFilename; + if( !FPackageName::DoesPackageExist( AssetData.PackageName.ToString(), &PackageFilename ) ) + { + // Could not determine filename for package so we can not delete + continue; + } + + UPackage* Package = FindPackage(nullptr, *AssetData.PackageName.ToString()); + if ( Package ) + { + PackageFilesToDelete.Add(Package); + } + } + } + + int32 NumObjectsToDelete = ObjectsToDelete.Num(); + if ( NumObjectsToDelete > 0 ) + { + ObjectTools::ForceDeleteObjects( ObjectsToDelete, false ); + } + + const int32 NumPackagesToDelete = PackageFilesToDelete.Num(); + if (NumPackagesToDelete > 0) + { + TArray PackagePointers; + for ( const auto& PkgIt : PackageFilesToDelete ) + { + UPackage* Package = PkgIt.Get(); + if ( Package ) + { + PackagePointers.Add(Package); + } + } + + if ( PackagePointers.Num() > 0 ) + { + const bool bPerformReferenceCheck = true; + ObjectTools::CleanupAfterSuccessfulDelete(PackagePointers, bPerformReferenceCheck); + } + } +} + diff --git a/Plugins/SUDS/Source/SUDSEditor/Private/SUDSScriptImporter.cpp b/Plugins/SUDS/Source/SUDSEditor/Private/SUDSScriptImporter.cpp new file mode 100644 index 00000000..cf1d9126 --- /dev/null +++ b/Plugins/SUDS/Source/SUDSEditor/Private/SUDSScriptImporter.cpp @@ -0,0 +1,2765 @@ +// Copyright Steve Streeting 2022 +// Released under the MIT license https://opensource.org/license/MIT/ +#include "SUDSScriptImporter.h" + +#include "SUDSEditorSettings.h" +#include "SUDSExpression.h" +#include "SUDSMessageLogger.h" +#include "SUDSScript.h" +#include "SUDSScriptNode.h" +#include "SUDSScriptNodeEvent.h" +#include "SUDSScriptNodeGosub.h" +#include "SUDSScriptNodeSet.h" +#include "SUDSScriptNodeText.h" +#include "Internationalization/Regex.h" +#include "Internationalization/StringTable.h" +#include "Internationalization/StringTableCore.h" + +class USUDSEditorSettings; +const FString FSUDSScriptImporter::EndGotoLabel = "end"; +const FString FSUDSScriptImporter::TreePathSeparator = "/"; + +DEFINE_LOG_CATEGORY(LogSUDSImporter) + +bool FSUDSScriptImporter::ImportFromBuffer(const TCHAR *Start, int32 Length, const FString& NameForErrors, FSUDSMessageLogger* Logger, bool bSilent) +{ + static const TCHAR* LineEndings[] = + { + TEXT("\r\n"), + TEXT("\r"), + TEXT("\n"), + }; + constexpr int32 NumDelims = UE_ARRAY_COUNT(LineEndings); + + int LineNumber = 1; + HeaderTree.Reset(); + BodyTree.Reset(); + PersistentMetadata.Empty(); + TransientMetadata.Empty(); + UserMetadata.Empty(); + bHeaderDone = false; + bHeaderInProgress = false; + bTooLateForHeader = false; + bool bImportedOK = true; + ChoiceUniqueId = 0; + TextIDHighestNumber = 0; + bOverrideGenerateSpeakerLineForChoice.Reset(); + OverrideChoiceSpeakerID.Reset(); + ReferencedSpeakers.Empty(); + if (Start) + { + int32 SubstringBeginIndex = 0; + + // Iterate through string. + for(int32 i = 0; i < Length;) + { + int32 SubstringEndIndex = INDEX_NONE; + int32 DelimiterLength = 0; + + // Attempt each delimiter. + for(int32 DelimIndex = 0; DelimIndex < NumDelims; ++DelimIndex) + { + DelimiterLength = FCString::Strlen(LineEndings[DelimIndex]); + + // If we found a delimiter... + if (FCString::Strncmp(Start + i, LineEndings[DelimIndex], DelimiterLength) == 0) + { + // Mark the end of the substring. + SubstringEndIndex = i; + break; + } + } + + if (SubstringEndIndex != INDEX_NONE) + { + const int32 SubstringLength = SubstringEndIndex - SubstringBeginIndex; + FStringView Line = FStringView(Start + SubstringBeginIndex, SubstringLength); + if (!ParseLine(Line, LineNumber++, NameForErrors, Logger, bSilent)) + { + // Abort, error + bImportedOK = false; + break; + } + + + // Next substring begins at the end of the discovered delimiter. + SubstringBeginIndex = SubstringEndIndex + DelimiterLength; + i = SubstringBeginIndex; + } + else + { + ++i; + } + } + + // Add any remaining characters after the last delimiter. + const int32 SubstringLength = Length - SubstringBeginIndex; + const FStringView Line = FStringView(Start + SubstringBeginIndex, SubstringLength); + bImportedOK = ParseLine(Line, LineNumber++, NameForErrors, Logger, bSilent) && bImportedOK; + } + + ConnectRemainingNodes(HeaderTree, NameForErrors, Logger, bSilent); + ConnectRemainingNodes(BodyTree, NameForErrors, Logger, bSilent); + GenerateTextIDs(HeaderTree); + GenerateTextIDs(BodyTree); + + bImportedOK = PostImportSanityCheck(NameForErrors, Logger, bSilent) && bImportedOK; + + return bImportedOK; + +} + +bool FSUDSScriptImporter::ParseLine(const FStringView& Line, int LineNo, const FString& NameForErrors, FSUDSMessageLogger* Logger, bool bSilent) +{ + // Trim off any whitespace, but record how much of it there is since it can be relevant + int IndentLevel; + const FStringView TrimmedLine = TrimLine(Line, IndentLevel); + + if (TrimmedLine.Len() == 0 && !bTextInProgress) + { + // We will skip any blank lines that aren't inside text + if (!bSilent) + UE_LOG(LogSUDSImporter, VeryVerbose, TEXT("%3d:00: BLANK %s"), LineNo, *FString(Line)); + return true; + } + + if (IsCommentLine(TrimmedLine)) + { + // Skip over comment lines + if (!bSilent) + UE_LOG(LogSUDSImporter, VeryVerbose, TEXT("%3d:%2d: COMMENT %s"), LineNo, IndentLevel, *FString(Line)); + + // May be metadata in the comment though + ParseCommentMetadataLine(TrimmedLine, IndentLevel, LineNo, NameForErrors, Logger, bSilent); + + return true; + } + + // Check for headers + static const FStringView HeaderPrefix(TEXT("===")); + if (TrimmedLine.StartsWith(HeaderPrefix)) + { + if (bHeaderDone) + { + if (!bSilent) + Logger->Logf(ELogVerbosity::Error, TEXT("Failed to parse %s Line %d: Duplicate header section"), *NameForErrors, LineNo); + return false; + } + else if (bTooLateForHeader) + { + if (!bSilent) + Logger->Logf(ELogVerbosity::Error, TEXT("Failed to parse %s Line %d: Header section must be at start"), *NameForErrors, LineNo); + return false; + } + + if (bHeaderInProgress) + { + // End of header + bHeaderInProgress = false; + bHeaderDone = true; + } + else + { + bHeaderInProgress = true; + } + return true; + } + else if (bHeaderInProgress) + { + return ParseHeaderLine(TrimmedLine, IndentLevel, LineNo, NameForErrors, Logger, bSilent); + } + + // Process body + return ParseBodyLine(TrimmedLine, IndentLevel, LineNo, NameForErrors, Logger, bSilent); + +} + +bool FSUDSScriptImporter::ParseCommentMetadataLine(const FStringView& Line, + int IndentLevel, + int LineNo, + const FString& NameForErrors, + FSUDSMessageLogger* Logger, + bool bSilent) +{ + // Translator comments + // Comment metadata starts with: + // #= [Key:] Single Use Metadata (next line only) + // #+ [Key:] Persistent Metadata (apply to all lines until reset) + // [Key:] is optional; if omitted the key is "Comment" + // Persistent Metadata is reset when: + // - The same key is set again (can be set to blank to reset to empty) + // - A line that is more outdented than the source of the key is encountered + + if (Line.StartsWith(TEXT("#=")) || Line.StartsWith(TEXT("#+"))) + { + FString LineStr(Line); + const FRegexPattern MetaPattern(TEXT("^#([\\=\\+])\\s*(?:(\\S*)\\s*:\\s*)?(.*)$")); + FRegexMatcher MetaRegex(MetaPattern, LineStr); + if (MetaRegex.FindNext()) + { + if (!bSilent) + UE_LOG(LogSUDSImporter, VeryVerbose, TEXT("%3d:%2d: META : %s"), LineNo, IndentLevel, *FString(Line)); + + const bool bIsPersistent = MetaRegex.GetCaptureGroup(1) == "+"; + // There is no "count" of capture groups, test highest to detect if key used + FString KeyStr = MetaRegex.GetCaptureGroup(2); + const FName Key = FName(KeyStr.IsEmpty() ? "Comment" :KeyStr); + const FString Value = MetaRegex.GetCaptureGroup(3).TrimStartAndEnd(); + + if (bIsPersistent) + { + TArray* pStack = PersistentMetadata.Find(Key); + if (!pStack) + { + // Only bother creating if non-empty + // If we find a blank and there's a stack there already, we do add an entry since blank overrides others in scope + if (!Value.IsEmpty()) + { + pStack = &PersistentMetadata.Add(Key); + } + } + + if (pStack) + { + // First we need to check if this line is less or equal indented; if so we have to strip out existing stack items + while (!pStack->IsEmpty() && IndentLevel <= pStack->Top().IndentLevel) + { + pStack->Pop(); + } + pStack->Push(ParsedMetadata(Key, Value, IndentLevel)); + } + } + else + { + if (Value.IsEmpty()) + { + // Reset + TransientMetadata.Remove(Key); + } + else + { + TransientMetadata.Add(Key, ParsedMetadata(Key, Value, IndentLevel)); + } + + } + return true; + } + else + { + Logger->Logf(ELogVerbosity::Warning, + TEXT( + "%s: Malformed translator comment on line %d, ignoring"), + *NameForErrors, + LineNo); + return false; + } + } + else if (Line.StartsWith(TEXT("#%"))) + { + // User custom metadata - always applies to next line only + // Similar syntax to set lines, except text isn't allowed (this is not player visible) + // #% Key = Value + // #% Key Value + FString LineStr(Line); + const FRegexPattern UserMetaPattern(TEXT("^#\\%\\s+(\\S+)\\s+(?:=\\s+)?(\\S.*)$")); + FRegexMatcher UserMetaRegex(UserMetaPattern, LineStr); + if (UserMetaRegex.FindNext()) + { + if (!bSilent) + UE_LOG(LogSUDSImporter, VeryVerbose, TEXT("%3d:%2d: USERMETA : %s"), LineNo, IndentLevel, *FString(Line)); + + FString Name = UserMetaRegex.GetCaptureGroup(1); + FString ExprStr = UserMetaRegex.GetCaptureGroup(2).TrimStartAndEnd(); // trim because capture accepts spaces in quotes + + FSUDSExpression Expr; + { + FString ParseError; + if (Expr.ParseFromString(ExprStr, &ParseError)) + { + if (Expr.IsTextLiteral()) + { + if (!bSilent) + Logger->Logf(ELogVerbosity::Warning, TEXT("Error in %s line %d: Text values not allowed in user metadata"), *NameForErrors, LineNo); + return false; + } + else + { + UserMetadata.Add(FName(Name), Expr); + return true; + } + } + else + { + if (!bSilent) + Logger->Logf(ELogVerbosity::Warning, TEXT("Error in %s line %d: %s"), *NameForErrors, LineNo, *ParseError); + return false; + } + } + } + else + { + // Only a warning, in case other types of comment accidentally clash + Logger->Logf(ELogVerbosity::Warning, + TEXT( + "%s: Malformed user metadata comment on line %d, ignoring"), + *NameForErrors, + LineNo); + return false; + } + } + + return false; + +} + +TMap FSUDSScriptImporter::GetTextMetadataForNextEntry(int CurrentLineIndent) +{ + TMap Ret; + + // For each key + for (auto It = PersistentMetadata.CreateIterator(); It; ++It) + { + auto& Stack = It->Value; + // Use top of stack, so long as equally or less indented than current line + while (!Stack.IsEmpty() && CurrentLineIndent < Stack.Top().IndentLevel) + { + Stack.Pop(); + } + if (Stack.IsEmpty()) + { + // Remove key entry if there's no values left on the stack + It.RemoveCurrent(); + } + else + { + Ret.Add(It->Key, Stack.Top().Value); + } + } + + // Add transient after so they override persistent + for (auto& Pair: TransientMetadata) + { + // Always apply transient ones to next case, don't check indent + Ret.Add(Pair.Key, Pair.Value.Value); + } + TransientMetadata.Empty(); + + return Ret; +} + +TMap FSUDSScriptImporter::ConsumeUserMetadata() +{ + TMap Ret = UserMetadata; + UserMetadata.Empty(); + return Ret; +} + + +bool FSUDSScriptImporter::ParseHeaderLine(const FStringView& Line, int IndentLevel, int LineNo, const FString& NameForErrors, FSUDSMessageLogger* Logger, bool bSilent) +{ + if (!bSilent) + UE_LOG(LogSUDSImporter, VeryVerbose, TEXT("%3d:00: HEADER: %s"), LineNo, *FString(Line)); + + // Header can still have indenting, only a very limited set of functions though (conditionals) + while (HeaderTree.IndentLevelStack.Num() > 1 && + IndentLevel < HeaderTree.IndentLevelStack.Top().ThresholdIndent) + { + // Pop as much from the stack as necessary to return to this indent level + PopIndent(HeaderTree); + } + + if (HeaderTree.IndentLevelStack.IsEmpty()) + { + // Must be the first body line encountered. Add 1 indent level for the root + PushIndent(HeaderTree, -1, 0, ""); + } + + // Header lines include set / conditionals + // We ignore every other type of line + if (Line.StartsWith(TEXT('['))) + { + bool bParsed = ParseSetLine(Line, HeaderTree, 0, LineNo, NameForErrors, Logger, bSilent); + if (!bParsed) + bParsed = ParseImportSettingLine(Line, HeaderTree, 0, LineNo, NameForErrors, Logger, bSilent);; + } + + return true; +} + +bool FSUDSScriptImporter::ParseBodyLine(const FStringView& Line, + int IndentLevel, + int LineNo, + const FString& NameForErrors, + FSUDSMessageLogger* Logger, + bool bSilent) +{ + // Once we've had anything other than comments or blanks and non-headers, it's too late for headers + bTooLateForHeader = true; + + // Body indenting matters + // If less than "threshold indent", pop contexts from stack until that's no longer the case + // If more than threshold indent, may be a child of previous line. Increase threshold indent to this level + // ONLY a new context level IF previous was condition or choice. If previous was text then just a continuation + // This is why "threshold indent" remains as the outermost indent in this context + // If same as threshold indent, continuation of current context + + while (BodyTree.IndentLevelStack.Num() > 1 && + IndentLevel < BodyTree.IndentLevelStack.Top().ThresholdIndent) + { + // Pop as much from the stack as necessary to return to this indent level + PopIndent(BodyTree); + } + + if (BodyTree.IndentLevelStack.IsEmpty()) + { + // Must be the first body line encountered. Add 1 indent level for the root + PushIndent(BodyTree, -1, 0, ""); + } + + if (Line.StartsWith(TEXT('*'))) + { + return ParseChoiceLine(Line, BodyTree, IndentLevel, LineNo, NameForErrors, Logger, bSilent); + } + else if (Line.StartsWith(TEXT(':'))) + { + return ParseGotoLabelLine(Line, BodyTree, IndentLevel, LineNo, NameForErrors, Logger, bSilent); + } + else if (Line.StartsWith(TEXT('['))) + { + bool bParsed = ParseConditionalLine(Line, BodyTree, IndentLevel, LineNo, NameForErrors, Logger, bSilent); + if (!bParsed) + bParsed = ParseGotoLine(Line, BodyTree, IndentLevel, LineNo, NameForErrors, Logger, bSilent); + if (!bParsed) + bParsed = ParseSetLine(Line, BodyTree, IndentLevel, LineNo, NameForErrors, Logger, bSilent); + if (!bParsed) + bParsed = ParseEventLine(Line, BodyTree, IndentLevel, LineNo, NameForErrors, Logger, bSilent); + if (!bParsed) + bParsed = ParseGosubLine(Line, BodyTree, IndentLevel, LineNo, NameForErrors, Logger, bSilent); + if (!bParsed) + bParsed = ParseReturnLine(Line, BodyTree, IndentLevel, LineNo, NameForErrors, Logger, bSilent); + if (!bParsed) + bParsed = ParseImportSettingLine(Line, BodyTree, IndentLevel, LineNo, NameForErrors, Logger, bSilent); + if (!bParsed) + bParsed = ParseRandomLine(Line, BodyTree, IndentLevel, LineNo, NameForErrors, Logger, bSilent); + + if (!bParsed) + { + if (!bSilent) + { + UE_LOG(LogSUDSImporter, VeryVerbose, TEXT("%3d:%2d: CMD : %s"), LineNo, IndentLevel, *FString(Line)); + Logger->Logf(ELogVerbosity::Warning, TEXT("%s Line %d: Unrecognised command. Ignoring!"), *NameForErrors, LineNo); + } + // We still return true because we don't want to fail the entire import + } + return true; + } + else + { + return ParseTextLine(Line, BodyTree, IndentLevel, LineNo, NameForErrors, Logger, bSilent); + } +} + +bool FSUDSScriptImporter::IsLastNodeOfType(const FSUDSScriptImporter::ParsedTree& Tree, ESUDSParsedNodeType Type) +{ + auto& Ctx = Tree.IndentLevelStack.Top(); + return Tree.Nodes.IsValidIndex(Ctx.LastNodeIdx) && Tree.Nodes[Ctx.LastNodeIdx].NodeType == Type; +} + +int FSUDSScriptImporter::FindLastChoiceNode(const ParsedTree& Tree, int IndentLevel) +{ + int Ret = FindLastChoiceNode(Tree, IndentLevel,Tree.Nodes.Num() - 1, GetCurrentTreeConditionalPath(Tree)); + // if (Ret == -1) + // { + // // Fallback to try to find from previous text + // auto& Ctx = Tree.IndentLevelStack.Top(); + // + // if (Tree.Nodes.IsValidIndex(Ctx.LastTextNodeIdx)) + // { + // return FindChoiceAfterTextNode(Tree, Ctx.LastTextNodeIdx); + // } + // } + return Ret; +} + +int FSUDSScriptImporter::FindChoiceAfterTextNode(const FSUDSScriptImporter::ParsedTree& Tree, int TextNodeIdx, const FString& ConditionalPath) +{ + const auto& TextNode = Tree.Nodes[TextNodeIdx]; + + int RootChoiceIdx = -1; + if (TextNode.Edges.Num() == 1 && Tree.Nodes.IsValidIndex(TextNode.Edges[0].TargetNodeIdx)) + { + int NextNodeIdx = TextNode.Edges[0].TargetNodeIdx; + while (Tree.Nodes.IsValidIndex(NextNodeIdx)) + { + auto& Node = Tree.Nodes[NextNodeIdx]; + switch (Node.NodeType) + { + case ESUDSParsedNodeType::Select: + case ESUDSParsedNodeType::Text: + // Didn't find + break; + case ESUDSParsedNodeType::Choice: + RootChoiceIdx = NextNodeIdx; + NextNodeIdx = -1; // to break out of loop + break; + case ESUDSParsedNodeType::SetVariable: + case ESUDSParsedNodeType::Goto: + case ESUDSParsedNodeType::Event: + // Cascade through events to find the first choice + if (Node.Edges.Num() == 1) + { + NextNodeIdx = Node.Edges[0].TargetNodeIdx; + } + else + { + NextNodeIdx = -1; + } + break; + default: ; + }; + } + + if (RootChoiceIdx == -1) + { + return -1; + } + + // Now we've found the first choice after the text node + // BUT if there are conditional choices, there are select nodes underneath this, with + // more choice nodes. We want to find the deepest matching one and use the choice under that. + return RecurseChoiceNodeFindDeepest(Tree, RootChoiceIdx, ConditionalPath); + + } + return -1; + +} + +int FSUDSScriptImporter::RecurseChoiceNodeFindDeepest(const ParsedTree& Tree, + int FromChoiceIdx, + const FString& ConditionalPath) +{ + // We need to recurse from the Choice and any sequence of S->C->S->C is valid + // so long as the condition path matches. The deepest one is the best + // So here we're a Choice, and we can recurse into any Selects, and any Choices under that + // where the edge condition is a subset of ConditionalPath + + int Ret = FromChoiceIdx; + + if (Tree.Nodes.IsValidIndex(FromChoiceIdx)) + { + const auto& Choice = Tree.Nodes[FromChoiceIdx]; + for (int i = 0; i < Choice.Edges.Num(); ++i) + { + const auto& Edge = Choice.Edges[i]; + if (Edge.Text.IsEmpty() && Tree.Nodes.IsValidIndex(Edge.TargetNodeIdx) && Tree.Nodes[Edge.TargetNodeIdx].NodeType == ESUDSParsedNodeType::Select) + { + int ChildIdx = RecurseSelectNodeFindDeepestChoice(Tree, Edge.TargetNodeIdx, ConditionalPath); + // There should be only one that matches conditional + if (ChildIdx != -1) + { + Ret = ChildIdx; + break; + } + } + } + } + return Ret; +} + +int FSUDSScriptImporter::RecurseSelectNodeFindDeepestChoice(const ParsedTree& Tree, + int FromSelectNode, + const FString& ConditionalPath) +{ + // Default to not having found a choice that matches conditional + int Ret = -1; + + if (Tree.Nodes.IsValidIndex(FromSelectNode)) + { + const auto& Select = Tree.Nodes[FromSelectNode]; + for (int i = 0; i < Select.Edges.Num(); ++i) + { + const auto& Edge = Select.Edges[i]; + if (Tree.Nodes.IsValidIndex(Edge.TargetNodeIdx) && Tree.Nodes[Edge.TargetNodeIdx].NodeType == ESUDSParsedNodeType::Choice) + { + // It might be this choice node, IF edge matches the conditional path + FString ChoiceConditionalPath = GetTreeConditionalPath(Tree, Edge.TargetNodeIdx); + + if (ConditionalPath.StartsWith(ChoiceConditionalPath)) + { + // But also we recurse from here because there may be other nested Selects which match closer + int ChildIdx = RecurseChoiceNodeFindDeepest(Tree, Edge.TargetNodeIdx, ConditionalPath); + // There should be only one that matches conditional + if (ChildIdx != -1) + { + Ret = ChildIdx; + break; + } + } + } + } + } + return Ret; + +} + + +int FSUDSScriptImporter::FindLastChoiceNode(const ParsedTree& Tree, int IndentLevel, int FromIndex, const FString& ConditionPath) +{ + // Scan backwards from end of tree looking for a choice node which has the same or higher indent and condition state as current + // But abort if we hit text nodes on that path + + for (int i = FromIndex; i >= 0; --i) + { + auto& Node = Tree.Nodes[i]; + + if (ConditionPath.StartsWith(Node.ConditionalPath) && + Node.NodeType == ESUDSParsedNodeType::Text && + Node.OriginalIndent <= IndentLevel) + { + // We hit a parent text node, we can't go back any further + return -1; + } + // Only consider nodes on the same conditional path + // Note: NOT containing the conditional path. We don't want to skip over an intervening select node + if (ConditionPath == Node.ConditionalPath) + { + // note that we allow indents < as well as == + // This is so that if you choose to aesthetically indent choices it still works + if (Node.NodeType == ESUDSParsedNodeType::Choice && Node.OriginalIndent <= IndentLevel) + { + return i; + } + } + } + return -1; +} + +bool FSUDSScriptImporter::ParseChoiceLine(const FStringView& Line, + FSUDSScriptImporter::ParsedTree& Tree, + int IndentLevel, + int LineNo, + const FString& NameForErrors, + FSUDSMessageLogger* Logger, + bool bSilent) +{ + if (Line.StartsWith('*')) + { + if (!bSilent) + UE_LOG(LogSUDSImporter, VeryVerbose, TEXT("%3d:%2d: CHOICE: %s"), LineNo, IndentLevel, *FString(Line)); + + auto& Ctx = Tree.IndentLevelStack.Top(); + + + // Find a previous choice node to join with + int ChoiceNodeIdx = - 1; + // however, if there are pending goto labels, they MUST split the choice + if (Tree.PendingGotoLabels.Num() == 0) + { + ChoiceNodeIdx = FindLastChoiceNode(Tree, IndentLevel); + } + + // If the current indent context node is NOT a choice, create a choice node, and connect to previous node (using pending edge if needed) + if (ChoiceNodeIdx == -1) + { + // Didn't find a choice node we can connect to, so create one + + // However, before we do that, if our parent is a select node, we must ensure that its parent is a choice node + // This is just so that we have a choice node outside the select that any choices that come after the [endif] + // can attach to. If a non-conditional choice was encountered first, it'll already be there. But if the first + // choice was conditional, the select node will have been created first. + // We still create a choice node here too, underneath the select, to act as a holder for potentially multiple + // choices within this select condition + EnsureChoiceNodeExistsAboveSelect(Tree, IndentLevel, LineNo); + ChoiceNodeIdx = AppendNode(Tree, FSUDSParsedNode(ESUDSParsedNodeType::Choice, IndentLevel, LineNo)); + } + if (Tree.EdgeInProgressNodeIdx != -1) + { + // Must already have been a choice node but previous pending edge wasn't resolved + // This means it's a fallthrough, will be resolved at end + Tree.EdgeInProgressNodeIdx = Tree.EdgeInProgressEdgeIdx = -1; + } + + auto& ChoiceNode = Tree.Nodes[ChoiceNodeIdx]; + + // Inside each choice, everything should be indented at least as much as 1 character inside the * + // We provide the edge with context C001, C002 etc for fallthrough + PushIndent(Tree, ChoiceNodeIdx, IndentLevel + 1, FString::Printf(TEXT("C%03d"), ++ChoiceUniqueId)); + + // Do we want to generate a speaker line for this choice + bool bGenerateSpeakerLine = false; + FString GeneratedSpeakerID; + if (const auto Settings = GetDefault()) + { + bGenerateSpeakerLine = Settings->AlwaysGenerateSpeakerLinesFromChoices; + GeneratedSpeakerID = Settings->SpeakerIdForGeneratedLinesFromChoices; + } + if (bOverrideGenerateSpeakerLineForChoice.IsSet()) + { + bGenerateSpeakerLine = bOverrideGenerateSpeakerLineForChoice.GetValue(); + } + if (OverrideChoiceSpeakerID.IsSet()) + { + GeneratedSpeakerID = OverrideChoiceSpeakerID.GetValue(); + } + int ChoiceTextStart = 1; + if (Line.Len() > 1 && Line[1] == '-') + { + // *- prefix, override speaker line + bGenerateSpeakerLine = false; + ChoiceTextStart = 2; + } + + // Add a pending edge, with the choice text + // Following things fill in the edge details, the next node to be parsed will finalise the destination + FString ChoiceTextID; + auto ChoiceTextView = Line.SubStr(ChoiceTextStart, Line.Len() - ChoiceTextStart).TrimStart(); + // TextID might be blank after this, but that's OK, we fix later after we know what IDs are in use + RetrieveAndRemoveTextID(ChoiceTextView, ChoiceTextID); + const FString ChoiceText = FString(ChoiceTextView); + auto ChoiceTextMeta = GetTextMetadataForNextEntry(IndentLevel); + auto ChoiceUserMeta = ConsumeUserMetadata(); + const int EdgeIdx = ChoiceNode.Edges.Add(FSUDSParsedEdge(ChoiceNodeIdx, -1, LineNo, ChoiceText, ChoiceTextID, ChoiceTextMeta, ChoiceUserMeta)); + Tree.EdgeInProgressNodeIdx = ChoiceNodeIdx; + Tree.EdgeInProgressEdgeIdx = EdgeIdx; + + if (bGenerateSpeakerLine) + { + // We use the same text & ID so this is just one localisation entry + Ctx.LastTextNodeIdx = AppendNode(Tree, FSUDSParsedNode(GeneratedSpeakerID, ChoiceText, ChoiceTextID, ChoiceTextMeta, ChoiceUserMeta, IndentLevel + 1, LineNo)); + ReferencedSpeakers.AddUnique(GeneratedSpeakerID); + } + return true; + } + return false; +} + +FSUDSParsedEdge* FSUDSScriptImporter::GetEdgeInProgress(ParsedTree& Tree) +{ + if (Tree.Nodes.IsValidIndex(Tree.EdgeInProgressNodeIdx)) + { + auto& N = Tree.Nodes[Tree.EdgeInProgressNodeIdx]; + if (N.Edges.IsValidIndex(Tree.EdgeInProgressEdgeIdx)) + { + return &N.Edges[Tree.EdgeInProgressEdgeIdx]; + } + } + return nullptr; +} +void FSUDSScriptImporter::EnsureChoiceNodeExistsAboveSelect(ParsedTree& Tree, int IndentLevel, int LineNo) +{ + auto& Ctx = Tree.IndentLevelStack.Top(); + int ParentIdx = -1; + // Inserting is going to mess up EdgeInProgress + if (auto E = GetEdgeInProgress(Tree)) + { + ParentIdx = E->SourceNodeIdx; + } + else + { + ParentIdx = Ctx.LastNodeIdx; + } + + // Nothing to do if immediate parent isn't select + if (!Tree.Nodes.IsValidIndex(ParentIdx) || Tree.Nodes[ParentIdx].NodeType != ESUDSParsedNodeType::Select) + return; + + int TopSelectIdx = ParentIdx; + // Find the node above the top of potentially nested select chain + while (Tree.Nodes.IsValidIndex(ParentIdx) && Tree.Nodes[ParentIdx].NodeType == ESUDSParsedNodeType::Select) + { + TopSelectIdx = ParentIdx; + ParentIdx = Tree.Nodes[ParentIdx].ParentNodeIdx; + } + + if (Tree.Nodes.IsValidIndex(ParentIdx) && Tree.Nodes[ParentIdx].NodeType == ESUDSParsedNodeType::Choice) + { + // OK we found a choice above the select, this is OK + return; + } + + if (ParentIdx == -1) + { + // This means we hit the top of the chain without finding choice or select + // We have to trust that FindLastChoiceNode will find it & it's valid (previous placement should resolve) + + // Connect top select to choice under last text + if (Tree.Nodes.IsValidIndex(TopSelectIdx) && Tree.Nodes.IsValidIndex(Ctx.LastTextNodeIdx)) + { + // Choice node might not be directly underneath + const int ChoiceIdx = FindChoiceAfterTextNode(Tree, Ctx.LastTextNodeIdx, Tree.Nodes[TopSelectIdx].ConditionalPath); + if (ChoiceIdx != -1) + { + auto& SelNode = Tree.Nodes[TopSelectIdx]; + auto& ChoiceNode = Tree.Nodes[ChoiceIdx]; + FSUDSParsedEdge NewEdge(LineNo); + NewEdge.SourceNodeIdx = ChoiceIdx; + NewEdge.TargetNodeIdx = TopSelectIdx; + ChoiceNode.Edges.Add(NewEdge); + SelNode.ParentNodeIdx = ChoiceIdx; + } + } + return; + } + + // If we got here, we navigated to the point above the select(s) and didn't end up on a root choice + // So we need to insert one, after this index + const int InsertIdx = ParentIdx + 1; // this happens to return 0 for having hit the start of the tree (-1), which is fine + + Tree.Nodes.Insert(FSUDSParsedNode(ESUDSParsedNodeType::Choice, IndentLevel, LineNo), InsertIdx); + auto& NewChoice = Tree.Nodes[InsertIdx]; + auto& SelectNode = Tree.Nodes[InsertIdx + 1]; + // Add edge to the select, fixup the parent nodes for both + NewChoice.Edges.Add(FSUDSParsedEdge(InsertIdx, InsertIdx + 1, LineNo)); + NewChoice.ParentNodeIdx = SelectNode.ParentNodeIdx; + NewChoice.ChoicePath = SelectNode.ChoicePath; + NewChoice.ConditionalPath = SelectNode.ConditionalPath; + + // Now for every other node after this, we have to fix up indexes that are >= InsertIdx + // We don't fix up anything before, because we want things that pointed forward to the select to now point at the choice + for (int i = InsertIdx + 1; i < Tree.Nodes.Num(); ++i) + { + if (i != InsertIdx) + { + auto& N = Tree.Nodes[i]; + if (N.ParentNodeIdx >= InsertIdx) + ++N.ParentNodeIdx; + for (auto& E : N.Edges) + { + if (E.SourceNodeIdx >= InsertIdx) + ++E.SourceNodeIdx; + if (E.TargetNodeIdx >= InsertIdx) + ++E.TargetNodeIdx; + } + } + } + + // Also fix indent contexts + for (auto& I : Tree.IndentLevelStack) + { + if (I.LastNodeIdx >= InsertIdx) + ++I.LastNodeIdx; + if (I.LastTextNodeIdx >= InsertIdx) + ++I.LastTextNodeIdx; + } + // Also conditional blocks + for (auto& CB : Tree.ConditionalBlocks) + { + if (CB.SelectNodeIdx >= InsertIdx) + ++CB.SelectNodeIdx; + } + // Fixup edge in progress + if (Tree.EdgeInProgressNodeIdx >= InsertIdx) + ++Tree.EdgeInProgressNodeIdx; + + + // Manually change the select node parent index afterwards (if we change it before it'll get adjusted again) + SelectNode.ParentNodeIdx = InsertIdx; + + SetFallthroughForNewNode(Tree, NewChoice); + + +} + +bool FSUDSScriptImporter::IsConditionalLine(const FStringView& Line) +{ + return Line.StartsWith(TEXT("[if")) || + Line.StartsWith(TEXT("[else")) || + Line.StartsWith(TEXT("[endif")); +} + +bool FSUDSScriptImporter::ParseElseLine(const FStringView& Line, + FSUDSScriptImporter::ParsedTree& Tree, + int IndentLevel, + int LineNo, + const FString& NameForErrors, + FSUDSMessageLogger* Logger, + bool bSilent) +{ + if (Tree.ConditionalBlocks.IsValidIndex(Tree.CurrentConditionalBlockIdx)) + { + // "else" changes the current "if" or "elseif" block to "else" & creates a new condition-free edge + // Select or choice node should already be there + // Select node may be turned into a choice later if choice is the first thing encountered + + auto& Block = Tree.ConditionalBlocks[Tree.CurrentConditionalBlockIdx]; + if (Block.Stage != EConditionalStage::ElseStage) + { + Block.Stage = EConditionalStage::ElseStage; + // We need to give each else a unique condition string, otherwise sibling else's can be considered + // equivalent, when they in fact originate from different if's + // ID by select node index + Block.ConditionPathElement = MakeElseConditionPathElement(Block.SelectNodeIdx); + const int NodeIdx = Block.SelectNodeIdx; + + auto& SelectNode = Tree.Nodes[NodeIdx]; + const int EdgeIdx = SelectNode.Edges.Add(FSUDSParsedEdge(NodeIdx, -1, LineNo)); + Tree.EdgeInProgressNodeIdx = NodeIdx; + Tree.EdgeInProgressEdgeIdx = EdgeIdx; + // Wind back the last node to select + auto& Ctx = Tree.IndentLevelStack.Top(); + Ctx.LastNodeIdx = NodeIdx; + + + } + else + { + if (!bSilent) + Logger->Logf(ELogVerbosity::Error, TEXT("Error in %s line %d: cannot have more than one 'else'"), *NameForErrors, LineNo); + } + } + else + { + if (!bSilent) + Logger->Logf(ELogVerbosity::Error, TEXT("Error in %s line %d: 'else' with no matching 'if'"), *NameForErrors, LineNo); + } + + if (!bSilent) + UE_LOG(LogSUDSImporter, VeryVerbose, TEXT("%3d:%2d: ELSE : %s"), LineNo, IndentLevel, *FString(Line)); + return true; + +} + +FString FSUDSScriptImporter::MakeIfConditionPathElement(int SelectNodeIdx, + const FString& ConditionStr) +{ + return ConditionStr; +} + +FString FSUDSScriptImporter::MakeElseIfConditionPathElement(int SelectNodeIdx, + const FString& ConditionStr) +{ + return FString::Printf(TEXT("elseif-%d %s"), SelectNodeIdx, *ConditionStr); +} + +FString FSUDSScriptImporter::MakeElseConditionPathElement(int SelectNodeIdx) +{ + return FString::Printf(TEXT("else-%d"), SelectNodeIdx); +} + +bool FSUDSScriptImporter::ParseEndIfLine(const FStringView& Line, + FSUDSScriptImporter::ParsedTree& Tree, + int IndentLevel, + int LineNo, + const FString& NameForErrors, + FSUDSMessageLogger* Logger, + bool bSilent) +{ + if (Tree.ConditionalBlocks.IsValidIndex(Tree.CurrentConditionalBlockIdx)) + { + // Endif finishes the current block + const auto& Block = Tree.ConditionalBlocks[Tree.CurrentConditionalBlockIdx]; + Tree.CurrentConditionalBlockIdx = Block.PreviousBlockIdx; + // We must also clear the indent last node pointer, because we never want to auto-connect to conditionals + // We'll let the final fallthrough pass connect things + auto& Ctx = Tree.IndentLevelStack.Top(); + Ctx.LastNodeIdx = -1; + } + else + { + if (!bSilent) + Logger->Logf(ELogVerbosity::Error, TEXT("Error in %s line %d: 'endif' with no matching 'if'"), *NameForErrors, LineNo); + } + if (!bSilent) + UE_LOG(LogSUDSImporter, VeryVerbose, TEXT("%3d:%2d: ENDIF : %s"), LineNo, IndentLevel, *FString(Line)); + return true; + +} + +bool FSUDSScriptImporter::ParseIfLine(const FStringView& Line, + FSUDSScriptImporter::ParsedTree& Tree, + const FString& ConditionStr, + int IndentLevel, + int LineNo, + const FString& NameForErrors, + FSUDSMessageLogger* Logger, + bool bSilent) +{ + + if (!bSilent) + UE_LOG(LogSUDSImporter, VeryVerbose, TEXT("%3d:%2d: IF : %s"), LineNo, IndentLevel, *FString(Line)); + + // New if level always creates select node + const int NewNodeIdx = AppendNode(Tree, FSUDSParsedNode(ESUDSParsedNodeType::Select, IndentLevel, LineNo)); + auto& SelectNode = Tree.Nodes[NewNodeIdx]; + const int EdgeIdx = SelectNode.Edges.Add(FSUDSParsedEdge(NewNodeIdx, -1, LineNo)); + auto E = &SelectNode.Edges[EdgeIdx]; + { + FString ParseError; + if (!E->ConditionExpression.ParseFromString(ConditionStr, &ParseError)) + { + if (!bSilent) + Logger->Logf(ELogVerbosity::Error, TEXT("Error in %s line %d: %s"), *NameForErrors, LineNo, *ParseError); + } + } + Tree.EdgeInProgressNodeIdx = NewNodeIdx; + Tree.EdgeInProgressEdgeIdx = EdgeIdx; + + Tree.CurrentConditionalBlockIdx = Tree.ConditionalBlocks.Add( + ConditionalContext(NewNodeIdx, Tree.CurrentConditionalBlockIdx, EConditionalStage::IfStage, MakeIfConditionPathElement(NewNodeIdx, ConditionStr))); + + return true; + +} + +bool FSUDSScriptImporter::ParseElseIfLine(const FStringView& Line, + ParsedTree& Tree, + const FString& ConditionStr, + int IndentLevel, + int LineNo, + const FString& NameForErrors, + FSUDSMessageLogger* Logger, + bool bSilent) +{ + if (!bSilent) + UE_LOG(LogSUDSImporter, VeryVerbose, TEXT("%3d:%2d: ELSEIF: %s"), LineNo, IndentLevel, *FString(Line)); + + // "elseif" changes the current block state + // Select or choice node should already be there + // Select node may be turned into a choice later if choice is the first thing encountered + + if (Tree.ConditionalBlocks.IsValidIndex(Tree.CurrentConditionalBlockIdx)) + { + auto& Block = Tree.ConditionalBlocks[Tree.CurrentConditionalBlockIdx]; + if (Block.Stage != EConditionalStage::ElseStage) + { + Block.Stage = EConditionalStage::ElseIfStage; + // For the purposes of the block, the condition isn't just the condition contained here, + // it's also the negation of the original "if". This is to prevent multiple sibling + // elseifs merging if they contain the same condition but are attached to different ifs + Block.ConditionPathElement = MakeElseIfConditionPathElement(Block.SelectNodeIdx, ConditionStr); + const int NodeIdx = Block.SelectNodeIdx; + + auto& SelectOrChoiceNode = Tree.Nodes[NodeIdx]; + const int EdgeIdx = SelectOrChoiceNode.Edges.Add(FSUDSParsedEdge(NodeIdx, -1, LineNo)); + auto E = &SelectOrChoiceNode.Edges[EdgeIdx]; + { + FString ParseError; + if (!E->ConditionExpression.ParseFromString(ConditionStr, &ParseError)) + { + if (!bSilent) + Logger->Logf(ELogVerbosity::Error, TEXT("Error in %s line %d: %s"), *NameForErrors, LineNo, *ParseError); + } + } + Tree.EdgeInProgressNodeIdx = NodeIdx; + Tree.EdgeInProgressEdgeIdx = EdgeIdx; + } + else + { + if (!bSilent) + Logger->Logf(ELogVerbosity::Error, TEXT("Error in %s line %d: 'elseif' occurs after 'else'"), *NameForErrors, LineNo); + + } + + } + else + { + if (!bSilent) + Logger->Logf(ELogVerbosity::Error, TEXT("Error in %s line %d: 'elseif' with no matching 'if'"), *NameForErrors, LineNo); + } + + return true; +} + +bool FSUDSScriptImporter::ParseConditionalLine(const FStringView& Line, + FSUDSScriptImporter::ParsedTree& Tree, + int IndentLevel, + int LineNo, + const FString& NameForErrors, + FSUDSMessageLogger* Logger, + bool bSilent) +{ + + if (!IsConditionalLine(Line)) + return false; + + if (Line.Equals(TEXT("[else]"))) + { + return ParseElseLine(Line, Tree, IndentLevel, LineNo, NameForErrors, Logger, bSilent); + + } + else if (Line.Equals(TEXT("[endif]"))) + { + return ParseEndIfLine(Line, Tree, IndentLevel, LineNo, NameForErrors, Logger, bSilent); + } + else + { + const FString LineStr(Line); + const FRegexPattern IfPattern(TEXT("^\\[if\\s+(.+)\\]$")); + FRegexMatcher IfRegex(IfPattern, LineStr); + if (IfRegex.FindNext()) + { + const FString ConditionStr = IfRegex.GetCaptureGroup(1); + return ParseIfLine(Line, Tree, ConditionStr, IndentLevel, LineNo, NameForErrors, Logger, bSilent); + } + else + { + const FRegexPattern ElseIfPattern(TEXT("^\\[elseif\\s+(.+)\\]$")); + FRegexMatcher ElseIfRegex(ElseIfPattern, LineStr); + if (ElseIfRegex.FindNext()) + { + const FString ConditionStr = ElseIfRegex.GetCaptureGroup(1); + return ParseElseIfLine(Line, Tree, ConditionStr, IndentLevel, LineNo, NameForErrors, Logger, bSilent); + + } + } + } + + return false; +} + +bool FSUDSScriptImporter::ParseRandomLine(const FStringView& Line, + ParsedTree& Tree, + int IndentLevel, + int LineNo, + const FString& NameForErrors, + FSUDSMessageLogger* Logger, + bool bSilent) +{ + if (!IsRandomLine(Line)) + return false; + + else if (Line.Equals(TEXT("[random]"))) + { + return ParseBeginRandomLine(Line, Tree, IndentLevel, LineNo, NameForErrors, Logger, bSilent); + } + else if (Line.Equals(TEXT("[or]"))) + { + return ParseRandomOptionLine(Line, Tree, IndentLevel, LineNo, NameForErrors, Logger, bSilent); + + } + else if (Line.Equals(TEXT("[endrandom]"))) + { + return ParseEndRandomLine(Line, Tree, IndentLevel, LineNo, NameForErrors, Logger, bSilent); + } + + return false; +} + +bool FSUDSScriptImporter::IsRandomLine(const FStringView& Line) +{ + return Line.StartsWith(TEXT("[random")) || + Line.StartsWith(TEXT("[or")) || + Line.StartsWith(TEXT("[endrandom")); +} + +bool FSUDSScriptImporter::ParseBeginRandomLine(const FStringView& Line, + ParsedTree& Tree, + int IndentLevel, + int LineNo, + const FString& NameForErrors, + FSUDSMessageLogger* Logger, + bool bSilent) +{ + if (!bSilent) + UE_LOG(LogSUDSImporter, VeryVerbose, TEXT("%3d:%2d: RANDOM: %s"), LineNo, IndentLevel, *FString(Line)); + + // New random level always creates node + const int NewNodeIdx = AppendNode(Tree, FSUDSParsedNode(ESUDSParsedNodeType::Select, IndentLevel, LineNo)); + auto& SelectNode = Tree.Nodes[NewNodeIdx]; + const int EdgeIdx = SelectNode.Edges.Add(FSUDSParsedEdge(NewNodeIdx, -1, LineNo)); + FString ConditionStr = FString::Printf(TEXT("{%hs} == 0"), SUDS_RANDOMITEM_VAR); + auto E = &SelectNode.Edges[EdgeIdx]; + { + // Set the condition automatically based on internal random number var + FString ParseError; + if (!E->ConditionExpression.ParseFromString(ConditionStr, &ParseError)) + { + if (!bSilent) + Logger->Logf(ELogVerbosity::Error, TEXT("Error in %s line %d: %s"), *NameForErrors, LineNo, *ParseError); + } + } + + Tree.EdgeInProgressNodeIdx = NewNodeIdx; + Tree.EdgeInProgressEdgeIdx = EdgeIdx; + + Tree.CurrentConditionalBlockIdx = Tree.ConditionalBlocks.Add( + ConditionalContext(NewNodeIdx, Tree.CurrentConditionalBlockIdx, EConditionalStage::RandomStage, ConditionStr)); + + return true; + +} + +bool FSUDSScriptImporter::ParseRandomOptionLine(const FStringView& Line, + ParsedTree& Tree, + int IndentLevel, + int LineNo, + const FString& NameForErrors, + FSUDSMessageLogger* Logger, + bool bSilent) +{ + + // This is kind of like an auto-generated elseif + + if (!bSilent) + UE_LOG(LogSUDSImporter, VeryVerbose, TEXT("%3d:%2d: OR: %s"), LineNo, IndentLevel, *FString(Line)); + + // "elseif" changes the current block state + // Select or choice node should already be there + // Select node may be turned into a choice later if choice is the first thing encountered + + if (Tree.ConditionalBlocks.IsValidIndex(Tree.CurrentConditionalBlockIdx)) + { + auto& Block = Tree.ConditionalBlocks[Tree.CurrentConditionalBlockIdx]; + if (Block.Stage == EConditionalStage::RandomStage || + Block.Stage == EConditionalStage::RandomOptionStage) + { + Block.Stage = EConditionalStage::RandomOptionStage; + const int NodeIdx = Block.SelectNodeIdx; + + auto& SelectOrChoiceNode = Tree.Nodes[NodeIdx]; + // Generate condition based on auto-generated random item choice + Block.ConditionPathElement = FString::Printf(TEXT("{%hs} == %d"), SUDS_RANDOMITEM_VAR, SelectOrChoiceNode.Edges.Num()); + const int EdgeIdx = SelectOrChoiceNode.Edges.Add(FSUDSParsedEdge(NodeIdx, -1, LineNo)); + auto E = &SelectOrChoiceNode.Edges[EdgeIdx]; + { + FString ParseError; + if (!E->ConditionExpression.ParseFromString(Block.ConditionPathElement, &ParseError)) + { + if (!bSilent) + Logger->Logf(ELogVerbosity::Error, TEXT("Error in %s line %d: %s"), *NameForErrors, LineNo, *ParseError); + } + } + Tree.EdgeInProgressNodeIdx = NodeIdx; + Tree.EdgeInProgressEdgeIdx = EdgeIdx; + } + else + { + if (!bSilent) + Logger->Logf(ELogVerbosity::Error, TEXT("Error in %s line %d: 'or' must occur after 'random'"), *NameForErrors, LineNo); + + } + + } + else + { + if (!bSilent) + Logger->Logf(ELogVerbosity::Error, TEXT("Error in %s line %d: 'or' with no matching 'random'"), *NameForErrors, LineNo); + } + + return true; +} + +bool FSUDSScriptImporter::ParseEndRandomLine(const FStringView& Line, + ParsedTree& Tree, + int IndentLevel, + int LineNo, + const FString& NameForErrors, + FSUDSMessageLogger* Logger, + bool bSilent) +{ + // Similar to "endif"; except that we need to turn the last option into an "else", otherwise at end of + // parsing we'll add an extra else edge + if (Tree.ConditionalBlocks.IsValidIndex(Tree.CurrentConditionalBlockIdx)) + { + // Endrandom finishes the current block + const auto& Block = Tree.ConditionalBlocks[Tree.CurrentConditionalBlockIdx]; + if (Block.Stage == EConditionalStage::RandomStage || + Block.Stage == EConditionalStage::RandomOptionStage) + { + const int NodeIdx = Block.SelectNodeIdx; + auto& SelectNode = Tree.Nodes[NodeIdx]; + if (SelectNode.Edges.Num() > 0) + { + auto& LastEdge = SelectNode.Edges[SelectNode.Edges.Num()-1]; + LastEdge.ConditionExpression.Reset(); + } + + Tree.CurrentConditionalBlockIdx = Block.PreviousBlockIdx; + // We must also clear the indent last node pointer, because we never want to auto-connect to conditionals + // We'll let the final fallthrough pass connect things + auto& Ctx = Tree.IndentLevelStack.Top(); + Ctx.LastNodeIdx = -1; + } + else + { + if (!bSilent) + Logger->Logf(ELogVerbosity::Error, TEXT("Error in %s line %d: 'endrandom' with no matching 'random'"), *NameForErrors, LineNo); + } + } + else + { + if (!bSilent) + Logger->Logf(ELogVerbosity::Error, TEXT("Error in %s line %d: 'endrandom' with no matching 'random'"), *NameForErrors, LineNo); + } + if (!bSilent) + UE_LOG(LogSUDSImporter, VeryVerbose, TEXT("%3d:%2d: ENDRANDOM : %s"), LineNo, IndentLevel, *FString(Line)); + return true; + +} + + +bool FSUDSScriptImporter::ParseGotoLabelLine(const FStringView& Line, + FSUDSScriptImporter::ParsedTree& Tree, + int IndentLevel, + int LineNo, + const FString& NameForErrors, + FSUDSMessageLogger* Logger, + bool bSilent) +{ + // We've already established that line starts with ':' + // There should not be any spaces in the label + const FString LineStr(Line); + const FRegexPattern LabelPattern(TEXT("^\\:\\s*(\\w+)$")); + FRegexMatcher LabelRegex(LabelPattern, LineStr); + if (LabelRegex.FindNext()) + { + if (!bSilent) + UE_LOG(LogSUDSImporter, VeryVerbose, TEXT("%3d:%2d: LABEL : %s"), LineNo, IndentLevel, *FString(Line)); + // lowercase goto labels so case insensitive + FString Label = LabelRegex.GetCaptureGroup(1).ToLower(); + if (Label == EndGotoLabel) + { + if (!bSilent) + Logger->Logf(ELogVerbosity::Error, TEXT("Error in %s line %d: Label 'end' is reserved and cannot be used in the script, ignoring"), *NameForErrors, LineNo); + } + else + { + Tree.PendingGotoLabels.Add(Label); + // This will be connected to the next node created + // Is a list since multiple labels may resolve to the same place + } + } + else + { + if (!bSilent) + Logger->Logf(ELogVerbosity::Warning, TEXT("Error in %s line %d: Badly formed goto label"), *NameForErrors, LineNo); + } + // Always return true to carry on, may not be used + return true; +} + +bool FSUDSScriptImporter::ParseGotoLine(const FStringView& Line, + FSUDSScriptImporter::ParsedTree& Tree, + int IndentLevel, + int LineNo, + const FString& NameForErrors, + FSUDSMessageLogger* Logger, + bool bSilent) +{ + // Unfortunately FRegexMatcher doesn't support FStringView + const FString LineStr(Line); + // Allow both 'goto' and 'go to' + const FRegexPattern GotoPattern(TEXT("^\\[go[ ]?to\\s+(\\w+)\\s*\\]$")); + FRegexMatcher GotoRegex(GotoPattern, LineStr); + if (GotoRegex.FindNext()) + { + if (!bSilent) + UE_LOG(LogSUDSImporter, VeryVerbose, TEXT("%3d:%2d: GOTO : %s"), LineNo, IndentLevel, *FString(Line)); + // lower case label so case insensitive + const FString Label = GotoRegex.GetCaptureGroup(1).ToLower(); + // note that we do NOT try to resolve the goto label here, to allow forward jumps. + const auto& Ctx = Tree.IndentLevelStack.Top(); + // A goto is an edge from the current node to another node + // That means if we had pending labels that didn't hit a node before now, they're just aliases to THIS label + for (auto PendingLabel : Tree.PendingGotoLabels) + { + Tree.AliasedGotoLabels.Add(PendingLabel, Label); + } + Tree.PendingGotoLabels.Reset(); + AppendNode(Tree, FSUDSParsedNode(Label, IndentLevel, LineNo)); + return true; + } + return false; +} + +bool FSUDSScriptImporter::ParseGosubLine(const FStringView& InLine, + ParsedTree& Tree, + int IndentLevel, + int LineNo, + const FString& NameForErrors, + FSUDSMessageLogger* Logger, + bool bSilent) +{ + // Attempt to find existing ID + // We need these in order to save the return stack + FString GosubID; + FStringView Line = InLine; + // If this is a continuation line, we shouldn't generate one, but we need to trim it off if it's there + bool bFoundID = RetrieveAndRemoveGosubID(Line, GosubID); + + // Unfortunately FRegexMatcher doesn't support FStringView + const FString LineStr(Line); + // Allow both 'gosub' and 'go sub' + const FRegexPattern GosubPattern(TEXT("^\\[go[ ]?sub\\s+(\\w+)\\s*\\]$")); + FRegexMatcher GosubRegex(GosubPattern, LineStr); + if (GosubRegex.FindNext()) + { + if (!bSilent) + UE_LOG(LogSUDSImporter, VeryVerbose, TEXT("%3d:%2d: GOSUB : %s"), LineNo, IndentLevel, *FString(Line)); + // lower case label so case insensitive + const FString Label = GosubRegex.GetCaptureGroup(1).ToLower(); + + // You CANNOT "gosub end" + if (Label == EndGotoLabel) + { + if (!bSilent) + Logger->Logf(ELogVerbosity::Error, TEXT("Error in %s line %d: You cannot 'gosub end', will never return. Did you mean goto?"), *NameForErrors, LineNo); + + } + else + { + // note that we do NOT try to resolve the label here, to allow forward jumps. + const auto& Ctx = Tree.IndentLevelStack.Top(); + // A gosub will become a node of its own in the final runtime + // Therefore we don't need to alias labels like we do with gotos + AppendNode(Tree, FSUDSParsedNode(Label, GosubID, IndentLevel, LineNo)); + } + return true; + } + return false; + +} + +bool FSUDSScriptImporter::ParseReturnLine(const FStringView& Line, + ParsedTree& Tree, + int IndentLevel, + int LineNo, + const FString& NameForErrors, + FSUDSMessageLogger* Logger, + bool bSilent) +{ + // Unfortunately FRegexMatcher doesn't support FStringView + const FString LineStr(Line); + const FRegexPattern ReturnPattern(TEXT("^\\[return\\s*\\]$")); + FRegexMatcher ReturnRegex(ReturnPattern, LineStr); + if (ReturnRegex.FindNext()) + { + if (!bSilent) + UE_LOG(LogSUDSImporter, VeryVerbose, TEXT("%3d:%2d: RETURN : %s"), LineNo, IndentLevel, *FString(Line)); + AppendNode(Tree, FSUDSParsedNode(ESUDSParsedNodeType::Return, IndentLevel, LineNo)); + return true; + } + return false; +} + +bool FSUDSScriptImporter::ParseSetLine(const FStringView& InLine, + FSUDSScriptImporter::ParsedTree& Tree, + int IndentLevel, + int LineNo, + const FString& NameForErrors, + FSUDSMessageLogger* Logger, + bool bSilent) +{ + // Attempt to find existing text ID, for string literals + // For multiple lines, may not be present until last line (but in fact can be on any line) + // We generate anyway, because it can be overriden by later lines, but makes sure we have one always + FString TextID; + FStringView Line = InLine; + // TextID may be blank after this, that's OK - we fix at the end once we know what IDs are used + RetrieveAndRemoveTextID(Line, TextID); + + // Unfortunately FRegexMatcher doesn't support FStringView + const FString LineStr(Line); + // Accept forms: + // [set Var Expression] + // [set Var = Expression] (more readable in the case of non-trivial expressions) + const FRegexPattern SetPattern(TEXT("^\\[set\\s+(\\S+)\\s+(?:=\\s+)?([^\\]]+)\\]$")); + FRegexMatcher SetRegex(SetPattern, LineStr); + if (SetRegex.FindNext()) + { + if (!bSilent) + UE_LOG(LogSUDSImporter, VeryVerbose, TEXT("%3d:%2d: SET : %s"), LineNo, IndentLevel, *FString(Line)); + + FString Name = SetRegex.GetCaptureGroup(1); + FString ExprStr = SetRegex.GetCaptureGroup(2).TrimStartAndEnd(); // trim because capture accepts spaces in quotes + + FSUDSExpression Expr; + { + FString ParseError; + if (Expr.ParseFromString(ExprStr, &ParseError)) + { + if (Expr.IsTextLiteral()) + { + AppendNode(Tree, FSUDSParsedNode(Name, Expr, TextID, IndentLevel, LineNo)); + } + else + { + AppendNode(Tree, FSUDSParsedNode(Name, Expr, IndentLevel, LineNo)); + } + return true; + } + else + { + if (!bSilent) + Logger->Logf(ELogVerbosity::Error, TEXT("Error in %s line %d: %s"), *NameForErrors, LineNo, *ParseError); + } + } + } + return false; +} + +bool FSUDSScriptImporter::ParseImportSettingLine(const FStringView& Line, + ParsedTree& Tree, + int IndentLevel, + int LineNo, + const FString& NameForErrors, + FSUDSMessageLogger* Logger, + bool bSilent) +{ + // Unfortunately FRegexMatcher doesn't support FStringView + const FString LineStr(Line); + const FRegexPattern ImportSetPattern(TEXT("^\\[importsetting\\s+(\\S+)\\s+(?:=\\s+)?([^\\]]+)\\]$")); + FRegexMatcher ImportSetRegex(ImportSetPattern, LineStr); + if (ImportSetRegex.FindNext()) + { + if (!bSilent) + UE_LOG(LogSUDSImporter, VeryVerbose, TEXT("%3d:%2d: IMPORTSETTING: %s"), LineNo, IndentLevel, *FString(Line)); + + const FString Name = ImportSetRegex.GetCaptureGroup(1); + const FString ExprStr = ImportSetRegex.GetCaptureGroup(2).TrimStartAndEnd(); // trim because capture accepts spaces in quotes + + FSUDSExpression Expr; + { + FString ParseError; + if (Expr.ParseFromString(ExprStr, &ParseError)) + { + if (Expr.IsLiteral()) + { + // Import settings affect importer state directly, no nodes + if (Name.Compare("GenerateSpeakerLinesFromChoices", ESearchCase::IgnoreCase) == 0) + { + if (Expr.GetLiteralValue().GetType() == ESUDSValueType::Boolean) + { + bOverrideGenerateSpeakerLineForChoice = Expr.GetBooleanLiteralValue(); + } + else + { + if (!bSilent) + Logger->Logf(ELogVerbosity::Error, TEXT("Error in %s line %d: [importsetting GenerateSpeakerLinesFromChoices ...] requires a boolean literal"), *NameForErrors, LineNo); + } + } + else if (Name.Compare("SpeakerIDForGeneratedLinesFromChoices", ESearchCase::IgnoreCase) == 0) + { + if (Expr.GetLiteralValue().GetType() == ESUDSValueType::Name) + { + // Speaker IDs are strings, but we go via FName to avoid translation + OverrideChoiceSpeakerID = Expr.GetNameLiteralValue().ToString(); + } + else + { + if (!bSilent) + Logger->Logf(ELogVerbosity::Error, TEXT("Error in %s line %d: [importsetting SpeakerIDForGeneratedLinesFromChoices ...] requires a Name literal e.g. (`Value`)"), *NameForErrors, LineNo); + } + } + + return true; + } + else + { + if (!bSilent) + Logger->Logf(ELogVerbosity::Error, TEXT("Error in %s line %d: importsetting only accepts literal values"), *NameForErrors, LineNo); + } + } + else + { + if (!bSilent) + Logger->Logf(ELogVerbosity::Error, TEXT("Error in %s line %d: %s"), *NameForErrors, LineNo, *ParseError); + } + } + } + return false; + +} + +bool FSUDSScriptImporter::ParseEventLine(const FStringView& Line, + FSUDSScriptImporter::ParsedTree& Tree, + int IndentLevel, + int LineNo, + const FString& NameForErrors, + FSUDSMessageLogger* Logger, + bool bSilent) +{ + const FString LineStr(Line); + const FRegexPattern EventPattern(TEXT("^\\[event\\s+([\\w\\.]+)([^\\]]*)\\]$")); + FRegexMatcher EventRegex(EventPattern, LineStr); + if (EventRegex.FindNext()) + { + if (!bSilent) + UE_LOG(LogSUDSImporter, VeryVerbose, TEXT("%3d:%2d: EVENT : %s"), LineNo, IndentLevel, *FString(Line)); + + FSUDSParsedNode Node(ESUDSParsedNodeType::Event, IndentLevel, LineNo); + + Node.Identifier = EventRegex.GetCaptureGroup(1); + + if (EventRegex.GetCaptureGroupBeginning(2) != INDEX_NONE) + { + // Has arguments, all lumped together + // Capture using a sub-regex which can detect quoted strings, split by commas + FString AllArgs = EventRegex.GetCaptureGroup(2).TrimStartAndEnd(); + const FRegexPattern ArgPattern(TEXT("((\\\"[^\\\"]*\\\"|[^,\\\"]+))")); + FRegexMatcher ArgRegex(ArgPattern, AllArgs); + while (ArgRegex.FindNext()) + { + // then process the quote + FString ArgStr = ArgRegex.GetCaptureGroup(1).TrimStartAndEnd(); + if (ArgStr.Len() == 0) + continue; + + FSUDSExpression Expr; + FString ParseError; + if (Expr.ParseFromString(ArgStr, &ParseError)) + { + // note: no localisation of event literals, they're just strings + // we assume the receiver of the event will set localised text to variables if they want + Node.EventArgs.Add(Expr); + } + else + { + if (!bSilent) + Logger->Logf(ELogVerbosity::Warning, TEXT("Error in %s line %d: Literal argument %d ('%s') invalid: %s"), *NameForErrors, LineNo, Node.EventArgs.Num() + 1, *ArgStr, *ParseError); + } + } + } + + AppendNode(Tree, Node); + + return true; + } + return false; +} + +bool FSUDSScriptImporter::ParseTextLine(const FStringView& InLine, + FSUDSScriptImporter::ParsedTree& Tree, + int IndentLevel, + int LineNo, + const FString& NameForErrors, + FSUDSMessageLogger* Logger, + bool bSilent) +{ + auto& Ctx = Tree.IndentLevelStack.Top(); + + // Attempt to find existing text ID + // For multiple lines, may not be present until last line (but in fact can be on any line) + FString TextID; + FStringView Line = InLine; + // Retrieve, but don't generate text ID at this point + // If this is a continuation line, we shouldn't generate one, but we need to trim it off if it's there + // TextID may be blank after this, that's OK - we fix at the end once we know what IDs are used + RetrieveAndRemoveTextID(Line, TextID); + + const FString LineStr(Line); + const FRegexPattern SpeakerPattern(TEXT("^(\\S+)\\:\\s*(.+)$")); + FRegexMatcher SpeakerRegex(SpeakerPattern, LineStr); + if (SpeakerRegex.FindNext()) + { + // OK this is a speaker line, in which case this is a new text node + const FString Speaker = SpeakerRegex.GetCaptureGroup(1); + const FString Text = SpeakerRegex.GetCaptureGroup(2); + if (!bSilent) + UE_LOG(LogSUDSImporter, VeryVerbose, TEXT("%3d:%2d: TEXT : %s"), LineNo, IndentLevel, *FString(Line)); + // New text node + // Text nodes can never introduce another indent context + // We've already backed out to the outer indent in caller + Ctx.LastTextNodeIdx = AppendNode(Tree, FSUDSParsedNode(Speaker, Text, TextID, GetTextMetadataForNextEntry(IndentLevel), ConsumeUserMetadata(), IndentLevel, LineNo)); + + ReferencedSpeakers.AddUnique(Speaker); + + return true; + } + + // If we fell through, this line is appended to the last text node + if (!bSilent) + UE_LOG(LogSUDSImporter, VeryVerbose, TEXT("%3d:%2d: TEXT+ : %s"), LineNo, IndentLevel, *FString(Line)); + if (Tree.Nodes.IsValidIndex(Ctx.LastNodeIdx)) + { + auto& Node = Tree.Nodes[Ctx.LastNodeIdx]; + if (Node.NodeType == ESUDSParsedNodeType::Text) + { + Node.Text.Appendf(TEXT("\n%s"), *LineStr); + } + else + { + if (!bSilent) + Logger->Logf(ELogVerbosity::Warning, TEXT("Error in %s line %d: Text newline continuation is not immediately after a speaker line. Ignoring."), *NameForErrors, LineNo); + // We still return true to allow continue + } + } + + return true; + + +} + +bool FSUDSScriptImporter::RetrieveAndRemoveTextID(FStringView& InOutLine, FString& OutTextID) +{ + + FString LineWithout; + int Number; + if (RetrieveTextIDFromLine(InOutLine, OutTextID, Number)) + { + TextIDHighestNumber = FMath::Max(TextIDHighestNumber, Number); + return true; + } + + return false; + +} + +bool FSUDSScriptImporter::RetrieveTextIDFromLine(FStringView& InOutLine, FString& OutTextID, int& OutNumber) +{ + const FString LineStr(InOutLine); + const FRegexPattern TextIDPattern(TEXT("(\\@([0-9a-fA-F]+)\\@)")); + FRegexMatcher TextIDRegex(TextIDPattern, LineStr); + if (TextIDRegex.FindNext()) + { + OutTextID = TextIDRegex.GetCaptureGroup(1); + // Chop the incoming string to the left of the TextID + InOutLine = InOutLine.Left(TextIDRegex.GetCaptureGroupBeginning(1)); + // Also trim right + InOutLine = InOutLine.TrimEnd(); + // FDefaultValueHelper::ParseInt requires an "0x" prefix but we're not using that + // Plus does extra checking we don't need + OutNumber = FCString::Strtoi(*TextIDRegex.GetCaptureGroup(2), nullptr, 16); + return true; + } + + return false; + +} + +bool FSUDSScriptImporter::RetrieveAndRemoveGosubID(FStringView& InOutLine, FString& OutTextID) +{ + + FString LineWithout; + int Number; + if (RetrieveGosubIDFromLine(InOutLine, OutTextID, Number)) + { + GosubIDHighestNumber = Number; + return true; + } + + return false; + +} + +bool FSUDSScriptImporter::RetrieveGosubIDFromLine(FStringView& InOutLine, FString& OutID, int& OutNumber) +{ + const FString LineStr(InOutLine); + const FRegexPattern IDPattern(TEXT("(\\@GS([0-9a-fA-F]+)\\@)")); + FRegexMatcher IDRegex(IDPattern, LineStr); + if (IDRegex.FindNext()) + { + OutID = IDRegex.GetCaptureGroup(1); + // Chop the incoming string to the left of the TextID + InOutLine = InOutLine.Left(IDRegex.GetCaptureGroupBeginning(1)); + // Also trim right + InOutLine = InOutLine.TrimEnd(); + // FDefaultValueHelper::ParseInt requires an "0x" prefix but we're not using that + // Plus does extra checking we don't need + OutNumber = FCString::Strtoi(*IDRegex.GetCaptureGroup(2), nullptr, 16); + return true; + } + + return false; + +} + +FString FSUDSScriptImporter::GenerateTextID() +{ + // Generate a new text ID just based on ascending numbers + // We don't actually base this on the line but we have it for future possible use + // Since it's a string, format exactly as in the sud file + return FString::Printf(TEXT("@%04x@"), ++TextIDHighestNumber); +} + +FString FSUDSScriptImporter::GetCurrentTreePath(const FSUDSScriptImporter::ParsedTree& Tree) +{ + // This is just a path of all the choice / select nodes AND their edges leading to this point, for fallthrough + // * Choice (/C000/) + // * Nested choice (/C000/C001/) + // Fallthrough from here + // * Choice (/C002/C003/) + // Do NOT fallthrough to here + // Fallthrough to here instead (/) + +#if ENGINE_MAJOR_VERSION ==5 && ENGINE_MINOR_VERSION >= 8 + TStringBuilder<256> B; +#else + FStringBuilderBase B; +#endif + for (auto Indent : Tree.IndentLevelStack) + { + B.Appendf(TEXT("%s%s"), *Indent.PathEntry, *TreePathSeparator); + } + return B.ToString(); +} + +FString FSUDSScriptImporter::GetCurrentTreeConditionalPath(const FSUDSScriptImporter::ParsedTree& Tree) +{ + // Like GetCurrentTreePath, but for conditional blocks + // Cannot fall through to blocks that aren't on the same conditional path +#if ENGINE_MAJOR_VERSION ==5 && ENGINE_MINOR_VERSION >= 8 + TStringBuilder<256> B; +#else + FStringBuilderBase B; +#endif + int BlockIdx = Tree.CurrentConditionalBlockIdx; + B.Append(TreePathSeparator); + // work backwards, hence prepend + while (BlockIdx != -1) + { + const FString& ConditionStr = Tree.ConditionalBlocks[BlockIdx].ConditionPathElement; + // Note: add the "/" even if ConditionStr is empty, because it means it's an else level + // Not including it can cause an if block to fall through to its own else + B.Prepend(FString::Printf(TEXT("%s%s"), *TreePathSeparator, *ConditionStr)); + BlockIdx = Tree.ConditionalBlocks[BlockIdx].PreviousBlockIdx; + } + return B.ToString(); + +} + +FString FSUDSScriptImporter::GetTreeConditionalPath(const FSUDSScriptImporter::ParsedTree& Tree, int NodeIndex) +{ + // Like GetCurrentTreeConditionalPath, but we're not in the block context. Follow the nodes back up +#if ENGINE_MAJOR_VERSION ==5 && ENGINE_MINOR_VERSION >= 8 + TStringBuilder<256> B; +#else + FStringBuilderBase B; +#endif + B.Append(TreePathSeparator); + + while (Tree.Nodes.IsValidIndex(NodeIndex)) + { + const auto Node = Tree.Nodes[NodeIndex]; + if (Tree.Nodes.IsValidIndex(Node.ParentNodeIdx)) + { + const auto& Parent = Tree.Nodes[Node.ParentNodeIdx]; + if (Parent.NodeType == ESUDSParsedNodeType::Select) + { + const int SelectNodeIdx = Node.ParentNodeIdx; + const int EdgeIdx = FindEdge(Tree, SelectNodeIdx, NodeIndex); + if (Parent.Edges.IsValidIndex(EdgeIdx)) + { + const auto& Edge = Parent.Edges[EdgeIdx]; + const bool bHasCondition = !Edge.ConditionExpression.IsEmpty(); + FString ConditionalPathElem; + if (EdgeIdx == 0) + { + // First edge is always if + ConditionalPathElem = MakeIfConditionPathElement(SelectNodeIdx, Edge.ConditionExpression.GetSourceString()); + } + else + { + if (bHasCondition) + { + // non-zero index edge with a condition is elseif + ConditionalPathElem = MakeElseIfConditionPathElement(SelectNodeIdx, Edge.ConditionExpression.GetSourceString()); + } + else + { + ConditionalPathElem = MakeElseConditionPathElement(SelectNodeIdx); + } + } + // working backwards, hence prepend + B.Prepend(FString::Printf(TEXT("%s%s"), *TreePathSeparator, *ConditionalPathElem)); + } + } + } + NodeIndex = Node.ParentNodeIdx; + } + + return B.ToString(); + +} + +int FSUDSScriptImporter::FindEdge(const FSUDSScriptImporter::ParsedTree& Tree, int ParentNodeIdx, int TargetNodeIndex) +{ + if (Tree.Nodes.IsValidIndex(ParentNodeIdx)) + { + const auto& Parent = Tree.Nodes[ParentNodeIdx]; + for (int i = 0; i < Parent.Edges.Num(); ++i) + { + if (Parent.Edges[i].TargetNodeIdx == TargetNodeIndex) + { + return i; + } + } + } + return -1; +} + +void FSUDSScriptImporter::SetFallthroughForNewNode(FSUDSScriptImporter::ParsedTree& Tree, FSUDSParsedNode& NewNode) +{ + // Choice nodes are allowed to be falled through to now + if (NewNode.NodeType == ESUDSParsedNodeType::Choice) + { + + // But, disable fallthrough for any parent select nodes all the way up the chain + int PrevIdx = NewNode.ParentNodeIdx; + while (Tree.Nodes.IsValidIndex(PrevIdx) && Tree.Nodes[PrevIdx].NodeType == ESUDSParsedNodeType::Select) + { + Tree.Nodes[PrevIdx].AllowFallthrough = false; + PrevIdx = Tree.Nodes[PrevIdx].ParentNodeIdx; + } + } + else + { + if (Tree.Nodes.IsValidIndex(NewNode.ParentNodeIdx)) + { + auto& PrevNode = Tree.Nodes[NewNode.ParentNodeIdx]; + // Do not allow fallthrough to this node for selects that are themselves children of other selects or choices + // It means they're compound conditionals / choices and we should only fall through to the root of that + if (NewNode.NodeType == ESUDSParsedNodeType::Select && + (PrevNode.NodeType == ESUDSParsedNodeType::Choice || PrevNode.NodeType == ESUDSParsedNodeType::Select)) + { + NewNode.AllowFallthrough = false; + } + } + } + +} + +int FSUDSScriptImporter::AppendNode(FSUDSScriptImporter::ParsedTree& Tree, const FSUDSParsedNode& InNode) +{ + auto& Ctx = Tree.IndentLevelStack.Top(); + + const int NewIndex = Tree.Nodes.Add(InNode); + + // Set the tree path of the node (post-add) + auto& NewNode = Tree.Nodes[NewIndex]; + NewNode.ChoicePath = GetCurrentTreePath(Tree); + NewNode.ConditionalPath = GetCurrentTreeConditionalPath(Tree); + + // Use pending edge if present; that could be because this is under a choice node, or a condition + if (auto E = GetEdgeInProgress(Tree)) + { + E->TargetNodeIdx = NewIndex; + + const int PrevNodeIdx = E->SourceNodeIdx; + NewNode.ParentNodeIdx = PrevNodeIdx; + SetFallthroughForNewNode(Tree, NewNode); + Tree.EdgeInProgressNodeIdx = -1; + Tree.EdgeInProgressEdgeIdx = -1; + } + else + { + const int PrevNodeIdx = Ctx.LastNodeIdx; + if (Tree.Nodes.IsValidIndex(PrevNodeIdx)) + { + // Append this node onto the last one + auto& PrevNode = Tree.Nodes[PrevNodeIdx]; + // Auto-connect new nodes to previous nodes + // Valid for nodes with only one output node + // A new node with no pending edge following any other type may be connected via fallthrough at + // the end of parsing + // Don't allow connection of fallthrough nodes to choices, unless they're to another select node + if (PrevNode.NodeType != ESUDSParsedNodeType::Choice || + (NewNode.NodeType == ESUDSParsedNodeType::Select)) + { + PrevNode.Edges.Add(FSUDSParsedEdge(PrevNodeIdx, NewIndex, InNode.SourceLineNo)); + NewNode.ParentNodeIdx = PrevNodeIdx; + } + + // Don't throw an error otherwise, because prev index can be a choice due to fallthrough + // This will be connected up at the end + + } + + SetFallthroughForNewNode(Tree, NewNode); + + } + + // All goto labels in scope at this point now point to this node + for (auto Label : Tree.PendingGotoLabels) + { + Tree.GotoLabelList.Add(Label, NewIndex); + } + Tree.PendingGotoLabels.Reset(); + + Ctx.LastNodeIdx = NewIndex; + Ctx.ThresholdIndent = FMath::Min(Ctx.ThresholdIndent, InNode.OriginalIndent); + + + return NewIndex; +} + +bool FSUDSScriptImporter::SelectNodeIsMissingElsePath(const FSUDSScriptImporter::ParsedTree& Tree, const FSUDSParsedNode& Node) +{ + for (auto& E : Node.Edges) + { + if (E.ConditionExpression.IsEmpty()) + { + // This is an else + return false; + } + } + + // If the first node the select points to (other than a nested select) is a choice, we never add an else + // note: you should never have an if/else where one branch has a set/text node and the other has a choice node, this is badly formed + // perhaps we should validate that + if (Node.Edges.Num() > 0) + { + int NextIdx = Node.Edges[0].TargetNodeIdx; + // While so that we can follow nested selects to first resolved + while (Tree.Nodes.IsValidIndex(NextIdx)) + { + auto N = Tree.Nodes[NextIdx]; + if (N.NodeType == ESUDSParsedNodeType::Select) + { + // Nested select, cascade down + if (N.Edges.Num() > 0) + { + NextIdx = N.Edges[0].TargetNodeIdx; + } + else + { + NextIdx = -1; + } + } + else + { + if (N.NodeType == ESUDSParsedNodeType::Choice) + { + // We never add fallthrough else paths for selection between choices + return false; + } + NextIdx = -1; + } + } + } + + return true; +} + +void FSUDSScriptImporter::PopIndent(FSUDSScriptImporter::ParsedTree& Tree) +{ + Tree.IndentLevelStack.Pop(); + // We *could* reset the pending goto list if there are labels at higher indents than current that never resolved to a node + // but I'm choosing not to right now and letting them fall through +} + +void FSUDSScriptImporter::PushIndent(FSUDSScriptImporter::ParsedTree& Tree, int NodeIdx, int Indent, const FString& Path) +{ + Tree.IndentLevelStack.Push(IndentContext(NodeIdx, Indent, Path)); + +} + +bool FSUDSScriptImporter::IsCommentLine(const FStringView& TrimmedLine) +{ + return TrimmedLine.StartsWith('#'); +} + +FStringView FSUDSScriptImporter::TrimLine(const FStringView& Line, int& OutIndentLevel) const +{ + OutIndentLevel = 0; + int32 SkippedChars = 0; + for (const TCHAR Char : Line) + { + if (!TChar::IsWhitespace(Char)) + { + break; + } + ++SkippedChars; + if (Char == '\t') + OutIndentLevel += TabIndentValue; + else + ++OutIndentLevel; + } + FStringView LeftTrimmed = FStringView(Line.GetData() + SkippedChars, Line.Len() - SkippedChars); + // Trim end, don't need to know how much it was + return LeftTrimmed.TrimEnd(); + +} + +void FSUDSScriptImporter::ConnectRemainingNodes(FSUDSScriptImporter::ParsedTree& Tree, const FString& NameForErrors, FSUDSMessageLogger* Logger, bool bSilent) +{ + // Now we go through all nodes, resolving gotos, and finding links that don't go anywhere & making + // them fall through to the next appropriate outdented node (or the end) + + // Firstly turn all the alias gotos into final gotos + for (auto& Alias : Tree.AliasedGotoLabels) + { + if (int* pIdx = Tree.GotoLabelList.Find(Alias.Value)) + { + Tree.GotoLabelList.Add(Alias.Key, *pIdx); + } + } + Tree.AliasedGotoLabels.Reset(); + + + // We go through top-to-bottom, which is the order of lines in the file as well + // We don't need to cascade for this + for (int i = 0; i < Tree.Nodes.Num(); ++i) + { + auto& Node = Tree.Nodes[i]; + // We check for dead-end nodes, and for select nodes with no "else" (in case all conditions fail) + const bool bIsSelectNodeMissingElse = Node.NodeType == ESUDSParsedNodeType::Select && SelectNodeIsMissingElsePath(Tree, Node); + if (Node.Edges.IsEmpty() || bIsSelectNodeMissingElse) + { + if (Node.NodeType == ESUDSParsedNodeType::Goto) + { + // Try to resolve goto now that we've parsed all labels + // We don't actually create edges here, the label is enough so long as it leads somewhere + // Check aliases first + FString Label = Node.Identifier; + // Special case 'end' which needs no further checking + if (Label != EndGotoLabel) + { + const int GotoNodeIdx = GetGotoTargetNodeIndex(Tree, Node.Identifier); + if (GotoNodeIdx == -1) + { + if (!bSilent) + Logger->Logf(ELogVerbosity::Warning, TEXT("Error in %s line %d: Goto label '%s' was not found, references to it will goto End"), *NameForErrors, Node.SourceLineNo, *Node.Identifier); + } + } + } + // Return nodes never fall through, they're always going back + // Gosubs DO come through here though, fallthrough is AFTER the return + else if (Node.NodeType != ESUDSParsedNodeType::Return) + { + // Find the next node which is at a higher indent level than this + // For a select node missing else, treat the indent as 1 inward, since it's really falling through from a nested part of the select + const int IndentLessThan = bIsSelectNodeMissingElse ? Node.OriginalIndent + 1 : Node.OriginalIndent; + const auto FallthroughIdx = FindFallthroughNodeIndex(Tree, i+1, Node.ChoicePath, Node.ConditionalPath); + if (Tree.Nodes.IsValidIndex(FallthroughIdx)) + { + Node.Edges.Add(FSUDSParsedEdge(i, FallthroughIdx, Node.SourceLineNo)); + } + else + { + // If no node to fallthrough to, default will be to end + } + } + } + else + { + for (auto& Edge : Node.Edges) + { + if (!Tree.Nodes.IsValidIndex(Edge.TargetNodeIdx)) + { + // Usually this is a choice line without anything under it, or a condition with nothing in it + const auto FallthroughIdx = FindFallthroughNodeIndex(Tree, i+1, Node.ChoicePath, Node.ConditionalPath); + if (Tree.Nodes.IsValidIndex(FallthroughIdx)) + { + Edge.TargetNodeIdx = FallthroughIdx; + } + else + { + // If no node to fallthrough to, goto end + Edge.TargetNodeIdx = -1; + } + } + } + } + } +} + +void FSUDSScriptImporter::GenerateTextIDs(ParsedTree& Tree) +{ + // This is where we generate any missing TextIDs for Text nodes, Set nodes (optionally) and Choice edges + // We don't want to do it as we go along, because if a script has some lines with TextIDs and new inserted lines + // in between, we won't know from a top-down scan which IDs have already been used. Later explicit TextIDs in + // the script could cause an ID clash with generated ones for inserted lines + + for (auto& Node : Tree.Nodes) + { + switch (Node.NodeType) + { + case ESUDSParsedNodeType::Text: + if (Node.TextID.IsEmpty()) + { + Node.TextID = GenerateTextID(); + } + break; + case ESUDSParsedNodeType::Choice: + // Text for choices is on edges + { + for (auto& Edge : Node.Edges) + { + if (!Edge.Text.IsEmpty() && Edge.TextID.IsEmpty()) + { + Edge.TextID = GenerateTextID(); + } + + // If this choice generated a speaker line, we need to give that the same text ID + // It will always be directly after the choice edge + if (Tree.Nodes.IsValidIndex(Edge.TargetNodeIdx)) + { + auto& NextNode = Tree.Nodes[Edge.TargetNodeIdx]; + if (NextNode.NodeType == ESUDSParsedNodeType::Text && NextNode.TextID.IsEmpty() && + NextNode.Text == Edge.Text) + { + NextNode.TextID = Edge.TextID; + } + } + } + } + break; + case ESUDSParsedNodeType::SetVariable: + if (Node.Expression.IsTextLiteral()) + { + // Text must be localised + if (Node.TextID.IsEmpty()) + { + Node.TextID = GenerateTextID(); + } + } + break; + default: + break; + } + + } + +} + +bool FSUDSScriptImporter::PostImportSanityCheck(const FString& NameForErrors, FSUDSMessageLogger* Logger, bool bSilent) +{ + bool bOK = true; + for (int i = 0; i < BodyTree.Nodes.Num(); ++i) + { + auto& Node = BodyTree.Nodes[i]; + + if (Node.NodeType == ESUDSParsedNodeType::Choice) + { + // Check all of them so we can report all errors, rather than early-out + bOK = ChoiceNodeCheckPaths(Node, NameForErrors, Logger, bSilent) && bOK; + } + } + // check for unfinished conditional blocks + if (BodyTree.ConditionalBlocks.IsValidIndex(BodyTree.CurrentConditionalBlockIdx)) + { + auto& Block = BodyTree.ConditionalBlocks[BodyTree.CurrentConditionalBlockIdx]; + auto SelectNode = BodyTree.Nodes.IsValidIndex(Block.SelectNodeIdx) ? &BodyTree.Nodes[Block.SelectNodeIdx] : nullptr; + if (!bSilent && SelectNode) + { + FString BlockStartDesc; + FString BlockEndDesc; + switch (Block.Stage) + { + case EConditionalStage::IfStage: + case EConditionalStage::ElseIfStage: + case EConditionalStage::ElseStage: + BlockStartDesc = "if"; + BlockEndDesc = "endif"; + break; + case EConditionalStage::RandomStage: + case EConditionalStage::RandomOptionStage: + BlockStartDesc = "random"; + BlockEndDesc = "endrandom"; + break; + } + Logger->Logf(ELogVerbosity::Error, + TEXT( + "%s: '%s' block started on line %d is missing '%s'"), + *NameForErrors, + *BlockStartDesc, + SelectNode->SourceLineNo, + *BlockEndDesc); + } + + } + + // Now check everything is referenced + TArray ReferencedNodes; + ReferencedNodes.SetNumZeroed(BodyTree.Nodes.Num()); + if (!ReferencedNodes.IsEmpty()) + { + // First node is always reachable + ReferencedNodes[0] = true; + } + for (const auto& Goto : BodyTree.GotoLabelList) + { + // Goto creates a reference + ReferencedNodes[Goto.Value] = true; + } + for (const auto& Node : BodyTree.Nodes) + { + for (const auto& Edge : Node.Edges) + { + if (ReferencedNodes.IsValidIndex(Edge.TargetNodeIdx)) + { + ReferencedNodes[Edge.TargetNodeIdx] = true; + } + } + } + + for (int i = 0; i < ReferencedNodes.Num(); ++i) + { + if (!ReferencedNodes[i]) + { + Logger->Logf(ELogVerbosity::Warning, + TEXT( + "%s: Line %d is unreachable. Check your indenting particularly under choice lines."), + *NameForErrors, + BodyTree.Nodes[i].SourceLineNo); + } + } + + return bOK; +} + +bool FSUDSScriptImporter::ChoiceNodeCheckPaths(const FSUDSParsedNode& ChoiceNode, + const FString& NameForErrors, + FSUDSMessageLogger* Logger, + bool bSilent) +{ + return RecurseChoiceNodeCheckPaths(ChoiceNode, ChoiceNode, NameForErrors, Logger, bSilent); +} + +bool FSUDSScriptImporter::RecurseChoiceNodeCheckPaths(const FSUDSParsedNode& OrigChoiceNode, + const FSUDSParsedNode& CurrChoiceNode, + const FString& NameForErrors, + FSUDSMessageLogger* Logger, + bool bSilent) +{ + bool bOK = true; + for (const auto& Edge: CurrChoiceNode.Edges) + { + // We want to make sure that every choice path leads to a speaker line, before it leads to another choice + // A choice that leads directly to another choice can't be properly represented in dialogue; choices have to + // be anchored by speaker lines so proceeding to another choice directly after a choice is made is wrong + // Usually this will be caused by a bad goto but could also be just bad nesting + // Every path has to lead to a speaker node before a choice + bOK = RecurseChoiceNodeCheckPaths(OrigChoiceNode, Edge, NameForErrors, Logger, bSilent) && bOK; + } + return bOK; +} + +bool FSUDSScriptImporter::RecurseChoiceNodeCheckPaths(const FSUDSParsedNode& ChoiceNode, const FSUDSParsedEdge& Edge, + const FString& NameForErrors, + FSUDSMessageLogger* Logger, + bool bSilent) +{ + const FSUDSParsedNode* TargetNode = GetNode(Edge.TargetNodeIdx); + while (TargetNode) + { + switch (TargetNode->NodeType) + { + case ESUDSParsedNodeType::Text: + // We're OK, found a speaker line + return true; + case ESUDSParsedNodeType::Choice: + // Definitely not ok, we found a choice node before a speaker node + Logger->Logf(ELogVerbosity::Error, + TEXT( + "%s: Choice '%s' on line %d needs a speaker line between it and the next choice at line %d. Choices MUST show another speaker line before the next choice."), + *NameForErrors, + *Edge.Text, + Edge.SourceLineNo, + TargetNode->SourceLineNo); + return false; + case ESUDSParsedNodeType::Select: + { + // Recurse selects & randoms; but in this case we can have nested choices underneath + bool bOK = true; + for (const auto& SelEdge : TargetNode->Edges) + { + const auto SelTarget = GetNode(SelEdge.TargetNodeIdx); + if (SelTarget) + { + if (SelTarget->NodeType == ESUDSParsedNodeType::Choice) + { + // First level of nested choices is OK; they will be combined with the original choice + // We don't need to recurse here since this choice will itself + } + else + { + bOK = RecurseChoiceNodeCheckPaths(ChoiceNode, SelEdge, NameForErrors, Logger, bSilent); + } + } + } + return bOK; + } + case ESUDSParsedNodeType::SetVariable: + case ESUDSParsedNodeType::Event: + // Continue (linear) + if (TargetNode->Edges.Num() > 0) + { + TargetNode = GetNode(TargetNode->Edges[0].TargetNodeIdx); + } + else + { + // This can happen if the event is the last line, but also nested in a choice + return true; + } + break; + case ESUDSParsedNodeType::Gosub: + case ESUDSParsedNodeType::Return: + // We can't really check the gosub/return statically, this will be a runtime error + return true; + case ESUDSParsedNodeType::Goto: + { + // Follow the goto (if end, will result in null) + const int GotoIdx = GetGotoTargetNodeIndex(BodyTree, TargetNode->Identifier); + TargetNode = GetNode(GotoIdx); + break; + } + } + } + + return true; +} + +int FSUDSScriptImporter::FindFallthroughNodeIndex(FSUDSScriptImporter::ParsedTree& Tree, + int StartNodeIndex, + const FString& FromChoicePath, + const FString& FromConditionalPath) +{ + // In order to be a valid fallthrough, also needs to be on the same choice (or select) path + // E.g. it's possible to have: + // + // * Choice (C1) + // * Nested choice (C1.1) + // Fallthrough from here (F1) + // * Choice (C2) + // Do NOT fallthrough to here (T1) + // Fallthrough to here instead (T2) + // + // Just testing the indent would fallthrough to T1 which is incorrect because it's not on the same choice path + // We need to check that the fallthrough point is on the same choice path (not just a common parent) + // Fallthrough from is on path /C1/C1.1 + // Point T1 is on /C2 which is NOT a subset of /C1/C1.1 so not OK + // Point T2 is on /, which is a subset of /C1/C1/1 so OK + // + // Also need to deal with this case: + // + // * Choice (C1) + // * Nested choice (C1.1) + // Fallthrough from here (F1) + // Fallthrough to here (T1) then from here (F2) + // * Choice (C2) + // Do NOT fallthrough to here (T2) + // Finally fall through to here (T3) + // + // In this case, fallthrough should first go to T1, then to T3 + // - Fallthrough F1 is on path /C1/C1.1 + // - Point T1 is on /C1 which IS a subset of /C1/C1.1 so OK + // - Fallthrough F2 is on path /C1 + // - Point T2 is on /C2 which is NOT a subset of /C1 so not OK + // - Point T3 is on / which is a subset of /C1 so OK + + // We'll form these paths just from node indexes rather than C1/C2 etc. Nesting can be for a choice or a select + for (int i = StartNodeIndex; i < Tree.Nodes.Num(); ++i) + { + auto N = Tree.Nodes[i]; + // We used to require that N.OriginalIndent < IndentLessThan here + // However, this is actually not needed, since indentation only controls association with choice paths, otherwise + // it's irrelevant. And we already check that things only fall through if they're on the same choice/conditional + // path (or a superset of it). + if (N.AllowFallthrough && + FromChoicePath.StartsWith(N.ChoicePath) && + FromConditionalPath.StartsWith(N.ConditionalPath)) + { + return i; + } + + } + + return -1; +} + +const FSUDSParsedNode* FSUDSScriptImporter::GetNode(const FSUDSScriptImporter::ParsedTree& Tree, int Index) +{ + if (Tree.Nodes.IsValidIndex(Index)) + { + return &Tree.Nodes[Index]; + } + + return nullptr; +} + +const FSUDSParsedNode* FSUDSScriptImporter::GetHeaderNode(int Index) +{ + return GetNode(HeaderTree, Index); +} + +const FSUDSParsedNode* FSUDSScriptImporter::GetNode(int Index) +{ + return GetNode(BodyTree, Index); +} + +int FSUDSScriptImporter::GetGotoTargetNodeIndex(const FString& InLabel) +{ + // Assume Body for this public version + return GetGotoTargetNodeIndex(BodyTree, InLabel); +} + +int FSUDSScriptImporter::GetGotoTargetNodeIndex(const ParsedTree& Tree, const FString& InLabel) +{ + FString Label = InLabel; + if (const FString* AliasLabel = Tree.AliasedGotoLabels.Find(Label)) + { + Label = *AliasLabel; + } + + // Resolve using goto list + if (const int *pGotoIdx = Tree.GotoLabelList.Find(Label)) + { + return *pGotoIdx; + } + + return -1; + +} + +void FSUDSScriptImporter::PopulateAsset(USUDSScript* Asset, UStringTable* StringTable) +{ + // This is only called if the parsing was successful + // Populate the runtime asset + TArray> *pOutNodes = nullptr; + TArray> *pOutHeaderNodes = nullptr; + TMap *pOutLabels = nullptr; + TMap *pOutHeaderLabels = nullptr; + TArray *pOutSpeakers = nullptr; + Asset->StartImport(&pOutNodes, &pOutHeaderNodes, &pOutLabels, &pOutHeaderLabels, &pOutSpeakers); + + pOutSpeakers->Append(ReferencedSpeakers); + + PopulateAssetFromTree(Asset, HeaderTree, pOutHeaderNodes, pOutHeaderLabels, StringTable); + PopulateAssetFromTree(Asset, BodyTree, pOutNodes, pOutLabels, StringTable); + + Asset->FinishImport(); +} + +FMD5Hash FSUDSScriptImporter::CalculateHash(const TCHAR* Buffer, int32 Len) +{ + FMD5Hash Hash; + FMD5 MD5; + + MD5.Update((uint8*)Buffer, Len * sizeof(TCHAR)); + + Hash.Set(MD5); + return Hash; + +} + +void FSUDSScriptImporter::PopulateAssetFromTree(USUDSScript* Asset, + const FSUDSScriptImporter::ParsedTree& Tree, + TArray>* pOutNodes, + TMap* pOutLabels, + UStringTable* StringTable) +{ + if (pOutNodes && pOutLabels) + { + TArray IndexRemap; + int OutIndex = 0; + // First pass, create all the nodes + for (const auto& InNode : Tree.Nodes) + { + // Gotos are dealt with in the node that references them, so ignore them + // We're going to be removing Goto nodes in the parse structure, because they were useful while parsing + // (letting you fallthrough to a goto node) but in the final runtime we just want them to be edges + // So firstly we need to figure out what the indexes of other nodes are going to be with them removed + if (InNode.NodeType == ESUDSParsedNodeType::Goto) + { + // note that this one goes nowhere, and don't increment dest index + IndexRemap.Add(-1); + } + else + { + IndexRemap.Add(OutIndex++); + + USUDSScriptNode* Node = nullptr; + switch (InNode.NodeType) + { + case ESUDSParsedNodeType::Text: + { +#if ENGINE_MAJOR_VERSION ==5 && ENGINE_MINOR_VERSION >= 8 + StringTable->GetMutableStringTable()->SetSourceString(InNode.TextID, InNode.Text, ""); +#else + StringTable->GetMutableStringTable()->SetSourceString(InNode.TextID, InNode.Text); +#endif + // Always include speaker metadata + StringTable->GetMutableStringTable()->SetMetaData(InNode.TextID, FName("Speaker"), InNode.Identifier); + // Other metadata + for (auto Pair : InNode.TextMetadata) + { + StringTable->GetMutableStringTable()->SetMetaData(InNode.TextID, Pair.Key, Pair.Value); + } + + auto TextNode = NewObject(Asset); + TextNode->Init(InNode.Identifier, FText::FromStringTable (StringTable->GetStringTableId(), InNode.TextID), InNode.SourceLineNo); + TextNode->SetUserMetadata(InNode.UserMetadata); + Node = TextNode; + break; + } + case ESUDSParsedNodeType::Choice: + { + auto ChoiceNode = NewObject(Asset); + ChoiceNode->InitChoice(InNode.SourceLineNo); + Node = ChoiceNode; + break; + } + case ESUDSParsedNodeType::Select: + { + auto SelectNode = NewObject(Asset); + SelectNode->InitSelect(InNode.SourceLineNo); + Node = SelectNode; + break; + } + case ESUDSParsedNodeType::SetVariable: + { + auto SetNode = NewObject(Asset); + // For text literals, re-point to string table + FSUDSExpression Expr = InNode.Expression; + if (Expr.IsTextLiteral()) + { +#if ENGINE_MAJOR_VERSION ==5 && ENGINE_MINOR_VERSION >= 8 + StringTable->GetMutableStringTable()->SetSourceString(InNode.TextID, Expr.GetTextLiteralValue().ToString(), ""); +#else + StringTable->GetMutableStringTable()->SetSourceString(InNode.TextID, Expr.GetTextLiteralValue().ToString()); +#endif + Expr.SetTextLiteralValue(FText::FromStringTable (StringTable->GetStringTableId(), InNode.TextID)); + } + SetNode->Init(InNode.Identifier, Expr, InNode.SourceLineNo); + Node = SetNode; + break; + } + case ESUDSParsedNodeType::Event: + { + auto EvtNode = NewObject(Asset); + EvtNode->Init(InNode.Identifier, InNode.EventArgs, InNode.SourceLineNo); + Node = EvtNode; + break; + } + case ESUDSParsedNodeType::Gosub: + { + // Validate gosub label at this point + auto GosubNode = NewObject(Asset); + GosubNode->Init(InNode.Identifier, InNode.TextID, InNode.SourceLineNo); + Node = GosubNode; + break; + } + case ESUDSParsedNodeType::Return: + { + auto ReturnNode = NewObject(Asset); + ReturnNode->InitReturn(InNode.SourceLineNo); + Node = ReturnNode; + break; + } + case ESUDSParsedNodeType::Goto: + // Gotos do not become nodes, just fixed edges + default: ; + break; + } + + pOutNodes->Add(Node); + + } + } + + // Second pass, create edges between nodes now that we know where everything is + for (int i = 0; i < Tree.Nodes.Num(); ++i) + { + const FSUDSParsedNode& InNode = Tree.Nodes[i]; + if (InNode.NodeType != ESUDSParsedNodeType::Goto) + { + USUDSScriptNode* Node = (*pOutNodes)[IndexRemap[i]]; + // Edges + if (InNode.Edges.Num() == 0) + { + // This normally happens with the final node in the script + // Make it an edge to nullptr for consistency + Node->AddEdge(FSUDSScriptEdge(nullptr, ESUDSEdgeType::Continue, InNode.SourceLineNo)); + } + else + { + for (auto& InEdge : InNode.Edges) + { + ESUDSEdgeType NewEdgeType; + + const FSUDSParsedNode *InTargetNode = GetNode(Tree, InEdge.TargetNodeIdx); + + switch (InNode.NodeType) + { + case ESUDSParsedNodeType::Text: + if (InTargetNode && InTargetNode->NodeType == ESUDSParsedNodeType::Choice) + { + // Text -> Choice is chained + NewEdgeType = ESUDSEdgeType::Chained; + } + else + { + NewEdgeType = ESUDSEdgeType::Continue; + } + break; + case ESUDSParsedNodeType::Choice: + if (InEdge.Text.IsEmpty() && InTargetNode && InTargetNode->NodeType == ESUDSParsedNodeType::Select) + { + // Choice->Select with no text is chained + NewEdgeType = ESUDSEdgeType::Chained; + } + else + { + NewEdgeType = ESUDSEdgeType::Decision; + } + break; + case ESUDSParsedNodeType::Select: + // All edges under selects are conditions + NewEdgeType = ESUDSEdgeType::Condition; + break; + default: + case ESUDSParsedNodeType::SetVariable: + case ESUDSParsedNodeType::Goto: + case ESUDSParsedNodeType::Event: + NewEdgeType = ESUDSEdgeType::Continue; + break; + }; + + USUDSScriptNode* TargetNode = nullptr; + + if (InTargetNode) + { + + if (InTargetNode->NodeType == ESUDSParsedNodeType::Goto) + { + // Resolve GOTOs immediately, point them directly at node goto points to + int Idx = GetGotoTargetNodeIndex(Tree, InTargetNode->Identifier); + // -1 means "Goto end", leave target null in that case + if (Idx != -1) + { + const int NewTargetIndex = IndexRemap[Idx]; + TargetNode = (*pOutNodes)[NewTargetIndex]; + } + } + else + { + const int NewTargetIndex = IndexRemap[InEdge.TargetNodeIdx]; + TargetNode = (*pOutNodes)[NewTargetIndex]; + } + + } + + FSUDSScriptEdge NewEdge(TargetNode, NewEdgeType, InEdge.SourceLineNo); + NewEdge.SetCondition(InEdge.ConditionExpression); + NewEdge.SetTargetNode(TargetNode); + + if (!InEdge.TextID.IsEmpty() && !InEdge.Text.IsEmpty()) + { +#if ENGINE_MAJOR_VERSION ==5 && ENGINE_MINOR_VERSION >= 8 + StringTable->GetMutableStringTable()->SetSourceString(InEdge.TextID, InEdge.Text, ""); +#else + StringTable->GetMutableStringTable()->SetSourceString(InEdge.TextID, InEdge.Text); +#endif + NewEdge.SetText(FText::FromStringTable(StringTable->GetStringTableId(), InEdge.TextID)); + // Always include speaker metadata, always the player in a choice + // Identify that it's a choice so translators know that there may be more limited space + StringTable->GetMutableStringTable()->SetMetaData(InEdge.TextID, FName("Speaker"), "Player (Choice)"); + // Other metadata + for (auto Pair : InEdge.TextMetadata) + { + StringTable->GetMutableStringTable()->SetMetaData(InEdge.TextID, Pair.Key, Pair.Value); + } + } + + NewEdge.SetUserMetadata(InEdge.UserMetadata); + Node->AddEdge(NewEdge); + + } + } + } + } + + // Add labels, so that dialogue can be entered at any label + // Aliases have already been resolved + for (auto& Elem : Tree.GotoLabelList) + { + int NewIndex = IndexRemap[Elem.Value]; + pOutLabels->Add(FName(Elem.Key), NewIndex); + } + + } + +} + diff --git a/Plugins/SUDS/Source/SUDSEditor/Private/SUDSScriptReimportFactory.cpp b/Plugins/SUDS/Source/SUDSEditor/Private/SUDSScriptReimportFactory.cpp new file mode 100644 index 00000000..c8a2f653 --- /dev/null +++ b/Plugins/SUDS/Source/SUDSEditor/Private/SUDSScriptReimportFactory.cpp @@ -0,0 +1,149 @@ +// Copyright Steve Streeting 2022 +// Released under the MIT license https://opensource.org/license/MIT/ +#include "SUDSScriptReimportFactory.h" + +#include "SUDSEditor.h" +#include "SUDSMessageLogger.h" +#include "SUDSScript.h" +#include "SUDSScriptNodeText.h" +#include "EditorFramework/AssetImportData.h" +#include "HAL/FileManager.h" +#include "Sound/DialogueWave.h" + +USUDSScriptReimportFactory::USUDSScriptReimportFactory() +{ + SupportedClass = USUDSScript::StaticClass(); + bCreateNew = false; + // We need to have a unique priority vs the original factory, so go after + ImportPriority = DefaultImportPriority - 1; +} + +bool USUDSScriptReimportFactory::CanReimport(UObject* Obj, TArray& OutFilenames) +{ + USUDSScript* Script = Cast(Obj); + if (Script && Script->AssetImportData) + { + Script->AssetImportData->ExtractFilenames(OutFilenames); + return true; + } + return false; +} + +void USUDSScriptReimportFactory::SetReimportPaths(UObject* Obj, const TArray& NewReimportPaths) +{ + USUDSScript* Script = Cast(Obj); + if (Script && ensure(NewReimportPaths.Num() == 1)) + { + Script->AssetImportData->UpdateFilenameOnly(NewReimportPaths[0]); + } + +} + +EReimportResult::Type USUDSScriptReimportFactory::Reimport(UObject* Obj) +{ + USUDSScript* Script = Cast(Obj); + if (!Script) + { + return EReimportResult::Failed; + } + + // Make sure file is valid and exists + const FString Filename = Script->AssetImportData->GetFirstFilename(); + if (!Filename.Len() || IFileManager::Get().FileSize(*Filename) == INDEX_NONE) + { + return EReimportResult::Failed; + } + + // When a new script is created, it actually lives at the same address as the incoming one. UE must re-use objects + // when you put them back at the same outer & asset name? + // This means if we want to preserve anything from the previously imported object, such as generated VO asset links, + // we need to copy those out now. + TMap PrevSpeakerVoices = Script->GetSpeakerVoices(); + // Store the TextID -> DialogueWave, but also store the line text as well so we can detect whether it matches & warn if not + TMap > PrevWaves; + for (auto Node : Script->GetNodes()) + { + if (auto TN = Cast(Node)) + { + if (auto W = TN->GetWave()) + { + PrevWaves.Add(TN->GetTextID(), TPair(TN->GetText().ToString(), W)); + } + } + } + + // Run the import again + EReimportResult::Type Result = EReimportResult::Failed; + bool OutCanceled = false; + + if (ImportObject(Script->GetClass(), Script->GetOuter(), *Script->GetName(), RF_Public | RF_Standalone, Filename, nullptr, OutCanceled) != nullptr) + { + UE_LOG(LogSUDSEditor, Log, TEXT("Imported successfully")); + + FSUDSMessageLogger Logger; + // Now, try to restore the speaker voice / line wave links from before + for (auto SpeakerID : Script->GetSpeakers()) + { + if (auto pVoice = PrevSpeakerVoices.Find(SpeakerID)) + { + Script->SetSpeakerVoice(SpeakerID, *pVoice); + } + } + for (auto Node : Script->GetNodes()) + { + if (auto TN = Cast(Node)) + { + if (auto pWavePair = PrevWaves.Find(TN->GetTextID())) + { + // Set the wave link either way + TN->SetWave(pWavePair->Value); + + // Check that the text is the same; if it's not, then lines have potentially changed + // we still live with the assignment we have (might be a minor edit) but user should be aware + if (TN->GetText().ToString() != pWavePair->Key) + { + Logger.Logf(ELogVerbosity::Error, + TEXT( + "TextID %s is linked to Dialogue Wave %s, but text has changed. Check whether this line is linked to the correct wave, and consider Writing String Keys back to script before making more script changes in future."), + *TN->GetTextID(), + *pWavePair->Value->GetName()); + } + + } + } + } + + Script->AssetImportData->Update(Filename); + + // Try to find the outer package so we can dirty it up + if (Script->GetOuter()) + { + Script->GetOuter()->MarkPackageDirty(); + } + else + { + Script->MarkPackageDirty(); + } + Result = EReimportResult::Succeeded; + } + else + { + if (OutCanceled) + { + UE_LOG(LogSUDSEditor, Warning, TEXT("-- import canceled")); + } + else + { + UE_LOG(LogSUDSEditor, Warning, TEXT("-- import failed")); + } + + Result = EReimportResult::Failed; + } + + return Result; +} + +int32 USUDSScriptReimportFactory::GetPriority() const +{ + return ImportPriority; +} diff --git a/Plugins/SUDS/Source/SUDSEditor/Public/SUDSEditor.h b/Plugins/SUDS/Source/SUDSEditor/Public/SUDSEditor.h new file mode 100644 index 00000000..e252027d --- /dev/null +++ b/Plugins/SUDS/Source/SUDSEditor/Public/SUDSEditor.h @@ -0,0 +1,24 @@ +// Copyright Steve Streeting 2022 +// Released under the MIT license https://opensource.org/license/MIT/ +#pragma once + +#include "CoreMinimal.h" +#include "Modules/ModuleInterface.h" + +class FSUDSScriptActions; +class FSlateStyleSet; + +DECLARE_LOG_CATEGORY_EXTERN(LogSUDSEditor, Verbose, All); + +class FSUDSEditorModule : public IModuleInterface +{ +public: + + /** IModuleInterface implementation */ + virtual void StartupModule() override; + virtual void ShutdownModule() override; + +protected: + TSharedPtr ScriptActions; + TSharedPtr StyleSet; +}; diff --git a/Plugins/SUDS/Source/SUDSEditor/Public/SUDSEditorScriptTools.h b/Plugins/SUDS/Source/SUDSEditor/Public/SUDSEditorScriptTools.h new file mode 100644 index 00000000..3db5fdb0 --- /dev/null +++ b/Plugins/SUDS/Source/SUDSEditor/Public/SUDSEditorScriptTools.h @@ -0,0 +1,22 @@ +// Copyright Steve Streeting 2022 +// Released under the MIT license https://opensource.org/license/MIT/ +#pragma once + +#include "CoreMinimal.h" + +class USUDSScript; +class USUDSScriptNode; +struct FSUDSMessageLogger; + +class FSUDSEditorScriptTools +{ +public: + + static void WriteBackTextIDs(USUDSScript* Script, FSUDSMessageLogger& Logger); + static bool WriteBackTextIDsFromNodes(const TArray Nodes, TArray& Lines, const FString& NameForErrors, FSUDSMessageLogger& Logger); + static bool WriteBackTextID(const FText& AssetText, int LineNo, TArray& Lines, const FString& NameForErrors, FSUDSMessageLogger& Logger); + static bool WriteBackGosubID(const FString& GosubID, int LineNo, TArray& Lines, const FString& NameForErrors, FSUDSMessageLogger& Logger); + static bool TextIDCheckMatch(const FText& AssetText, const FString& SourceLine); + + +}; diff --git a/Plugins/SUDS/Source/SUDSEditor/Public/SUDSEditorSettings.h b/Plugins/SUDS/Source/SUDSEditor/Public/SUDSEditorSettings.h new file mode 100644 index 00000000..5414eee6 --- /dev/null +++ b/Plugins/SUDS/Source/SUDSEditor/Public/SUDSEditorSettings.h @@ -0,0 +1,73 @@ +// Copyright Steve Streeting 2022 +// Released under the MIT license https://opensource.org/license/MIT/ +#pragma once + +#include "CoreMinimal.h" +#include "UObject/Object.h" +#include "SUDSEditorSettings.generated.h" + + +UENUM(BlueprintType) +enum class ESUDSAssetLocation : uint8 +{ + /// Use a single flat shared directory + SharedDirectory, + /// Use a shared base directory, but create subfolders based on the script asset name + SharedDirectorySubdir, + /// Place asset alongside the script that originated it + ScriptDirectory, + /// Place asset in a subfolder of the folder containing the script that generated it, named the same as the script + ScriptDirectorySubdir +}; + +class USUDSScript; +/** + * Settings for editor-specific aspects of SUDS (no effect at runtime) + */ +UCLASS(config = Editor, defaultconfig, meta=(DisplayName="SUDS Editor")) +class SUDSEDITOR_API USUDSEditorSettings : public UObject +{ + GENERATED_BODY() +public: + + UPROPERTY(config, EditAnywhere, Category = "Voice", meta = (Tooltip = "Where to place Dialogue Voice assets for speakers in scripts when generated", RelativeToGameContentDir, LongPackageName)) + ESUDSAssetLocation DialogueVoiceAssetLocation = ESUDSAssetLocation::SharedDirectory; + + UPROPERTY(config, EditAnywhere, Category = "Voice", meta = (Tooltip = "Shared directory for Dialogue Voice assets, if using a shared directory", RelativeToGameContentDir, LongPackageName)) + FDirectoryPath DialogueVoiceAssetSharedDir; + + UPROPERTY(config, EditAnywhere, Category = "Voice", meta = (Tooltip = "Where to place Dialogue Wave assets for speaker lines in scripts", RelativeToGameContentDir, LongPackageName)) + ESUDSAssetLocation DialogueWaveAssetLocation = ESUDSAssetLocation::ScriptDirectorySubdir; + + UPROPERTY(config, EditAnywhere, Category = "Voice", meta = (Tooltip = "Shared directory for Dialogue Wave assets, if using a shared directory", RelativeToGameContentDir, LongPackageName)) + FDirectoryPath DialogueWaveAssetSharedDir; + + UPROPERTY(config, EditAnywhere, Category = "Voice", meta = (Tooltip = "Prefix to give Dialogue Voice assets in front of their SpeakerID", RelativeToGameContentDir, LongPackageName)) + FString DialogueVoiceAssetPrefix = "DV_"; + UPROPERTY(config, EditAnywhere, Category = "Voice", meta = (Tooltip = "Prefix to give Dialogue Wave assets in front of their SpeakerID", RelativeToGameContentDir, LongPackageName)) + FString DialogueWaveAssetPrefix = "DW_"; + UPROPERTY(config, EditAnywhere, Category = "Voice", meta = (Tooltip = "When generating subdirectories and wave asset names from script names, whether to strip characters before the first '_' to avoid including script prefix", RelativeToGameContentDir, LongPackageName)) + bool StripScriptPrefixesWhenGeneratingNames = true; + + UPROPERTY(config, EditAnywhere, Category = "Voice", AdvancedDisplay, meta = (Tooltip = "Whether to auto-generate Dialogue Voice/Wave assets for ALL dialogue scripts on import. Note: you can always generate VO assets manually.")) + bool AlwaysAutoGenerateVoiceOverAssetsOnImport = false; + + UPROPERTY(config, EditAnywhere, Category = "Voice", AdvancedDisplay, meta = (Tooltip = "Auto-generate Dialogue Voice/Wave assets for scripts in these directories (and subdirectories) on import. Note: you can always generate VO assets manually.", RelativeToGameContentDir, LongPackageName)) + TArray DirectoriesToAutoGenerateVoiceOverAssetsOnImport; + + UPROPERTY(config, EditAnywhere, Category = "Choices", meta = (Tooltip = "Whether to generate a spoken line for choices (default false).")) + bool AlwaysGenerateSpeakerLinesFromChoices = false; + + UPROPERTY(config, EditAnywhere, Category = "Choices", meta = (Tooltip = "The SpeakerID to use for speaker lines generated from choices")) + FString SpeakerIdForGeneratedLinesFromChoices = "Player"; + + UPROPERTY(config, EditAnywhere, Category = "Assets", AdvancedDisplay, meta = (Tooltip = "Whether to create string tables as a separate package (.uasset) from the SUDS Script, which will cause them to appear separately in the Content Browser (requires script re-import)")) + bool bCreateStringTablesAsSeparatePackages = true; + + USUDSEditorSettings() {} + + bool ShouldGenerateVoiceAssets(const FString& PackagePath) const; + FString GetVoiceOutputDir(const FString& PackagePath, const FString& ScriptName) const; + FString GetWaveOutputDir(const FString& PackagePath, const FString& ScriptName) const; + static FString GetOutputDir(ESUDSAssetLocation Location, const FString& SharedPath, const FString& PackagePath, const FString& ScriptName); +}; diff --git a/Plugins/SUDS/Source/SUDSEditor/Public/SUDSEditorVoiceOverTools.h b/Plugins/SUDS/Source/SUDSEditor/Public/SUDSEditorVoiceOverTools.h new file mode 100644 index 00000000..47780487 --- /dev/null +++ b/Plugins/SUDS/Source/SUDSEditor/Public/SUDSEditorVoiceOverTools.h @@ -0,0 +1,34 @@ +// Copyright Steve Streeting 2022 +// Released under the MIT license https://opensource.org/license/MIT/ +#pragma once + +#include "CoreMinimal.h" +#include "SUDSScriptNodeText.h" +#include "Sound/DialogueVoice.h" + + +struct FSUDSMessageLogger; +class USUDSScript; + +class SUDSEDITOR_API FSUDSEditorVoiceOverTools +{ +public: + static void GenerateAssets(USUDSScript* Script, EObjectFlags Flags, FSUDSMessageLogger* Logger); +protected: + static void GenerateVoiceAssets(USUDSScript* Script, + EObjectFlags Flags, + FSUDSMessageLogger *Logger, + TMap &OutCreatedVoices); + static void GenerateWaveAssets(USUDSScript* Script, EObjectFlags Flags, TMap, FSUDSMessageLogger* Logger); + static bool GetSpeakerVoicePackageName(USUDSScript* Script, const FString& SpeakerID, FString& OutPackageName); + static bool GetSpeakerVoiceAssetNames(USUDSScript* Script, + const FString& SpeakerID, + FString& OutPackageName, + FString& OutAssetName); + static UDialogueVoice* FindSpeakerVoice(USUDSScript* Script, const FString& SpeakerID); + static FString GetScriptNameAsPrefix(USUDSScript* Script); + + static FString GetVoiceOutputDir(USUDSScript* Script); + static FString GetWaveOutputDir(USUDSScript* Script); + +}; diff --git a/Plugins/SUDS/Source/SUDSEditor/Public/SUDSMessageLogger.h b/Plugins/SUDS/Source/SUDSEditor/Public/SUDSMessageLogger.h new file mode 100644 index 00000000..0a95431a --- /dev/null +++ b/Plugins/SUDS/Source/SUDSEditor/Public/SUDSMessageLogger.h @@ -0,0 +1,60 @@ +// Copyright Steve Streeting 2022 +// Released under the MIT license https://opensource.org/license/MIT/ +#pragma once + +#include "CoreMinimal.h" +#include "Logging/TokenizedMessage.h" + +class FTokenizedMessage; + +struct SUDSEDITOR_API FSUDSMessageLogger +{ +protected: + TArray> ErrorMessages; + + bool bWriteToMessageLog = true; +public: + FSUDSMessageLogger() {} + FSUDSMessageLogger(bool bWriteToMsg) : bWriteToMessageLog(bWriteToMsg) {} + ~FSUDSMessageLogger(); + + void SetWriteToMessageLog(bool bWrite) { bWriteToMessageLog = bWrite; } + bool HasErrors() const; + int NumErrors() const; + bool HasWarnings() const; + int NumWarnings() const; + + bool GetWriteToMessageLog() const { return bWriteToMessageLog; } + void AddMessage(EMessageSeverity::Type Severity, const FText& Text); + +#if ENGINE_MAJOR_VERSION >= 5 && ENGINE_MINOR_VERSION >= 6 + template + FORCEINLINE void Logf(ELogVerbosity::Type Verbosity, UE::Core::TCheckedFormatString Fmt, Types... Args) +#else + template + FORCEINLINE void Logf(ELogVerbosity::Type Verbosity, const FmtType& Fmt, Types... Args) +#endif + { + EMessageSeverity::Type Sev = EMessageSeverity::Info; + switch(Verbosity) + { + case ELogVerbosity::Fatal: + case ELogVerbosity::Error: + Sev = EMessageSeverity::Error; + break; + case ELogVerbosity::Warning: + Sev = EMessageSeverity::Warning; + break; + default: ; + } + AddMessage(Sev, FText::FromString(FString::Printf(Fmt, Args...))); + } + + const TArray>& GetErrorMessages() const { return ErrorMessages; } + + /// Clear messages in preparation for an import + static void ClearMessages(); + + + +}; diff --git a/Plugins/SUDS/Source/SUDSEditor/Public/SUDSScriptActions.h b/Plugins/SUDS/Source/SUDSEditor/Public/SUDSScriptActions.h new file mode 100644 index 00000000..01cba953 --- /dev/null +++ b/Plugins/SUDS/Source/SUDSEditor/Public/SUDSScriptActions.h @@ -0,0 +1,37 @@ +// Copyright Steve Streeting 2022 +// Released under the MIT license https://opensource.org/license/MIT/ +#pragma once + +#include "CoreMinimal.h" +#include "AssetTypeActions_Base.h" +#include "SUDSScriptNodeGosub.h" +#include "SUDSScriptNodeText.h" + +class USUDSScriptNode; +class USUDSScript; +struct FSUDSMessageLogger; +/** + * + */ +class FSUDSScriptActions : public FAssetTypeActions_Base +{ +public: + virtual FText GetName() const override; + virtual FString GetObjectDisplayName(UObject* Object) const override; + virtual UClass* GetSupportedClass() const override; + virtual FColor GetTypeColor() const override; + virtual uint32 GetCategories() override; + virtual void GetResolvedSourceFilePaths(const TArray& TypeAssets, + TArray& OutSourceFilePaths) const override; + virtual bool IsImportedAsset() const override { return true; } + virtual void GetActions(const TArray& InObjects, FToolMenuSection& Section) override; + virtual bool HasActions(const TArray& InObjects) const override; + virtual void OpenAssetEditor(const TArray& InObjects, + TSharedPtr EditWithinLevelEditor) override; + +protected: + void WriteBackTextIDs(TArray> Scripts); + + void GenerateVOAssets(TArray> Scripts); + +}; diff --git a/Plugins/SUDS/Source/SUDSEditor/Public/SUDSScriptFactory.h b/Plugins/SUDS/Source/SUDSEditor/Public/SUDSScriptFactory.h new file mode 100644 index 00000000..ba773103 --- /dev/null +++ b/Plugins/SUDS/Source/SUDSEditor/Public/SUDSScriptFactory.h @@ -0,0 +1,37 @@ +// Copyright Steve Streeting 2022 +// Released under the MIT license https://opensource.org/license/MIT/ +#pragma once + +#include "CoreMinimal.h" +#include "SUDSScriptImporter.h" +#include "Factories/Factory.h" +#include "SUDSScriptFactory.generated.h" + + +/** + * + */ +UCLASS() +class SUDSEDITOR_API USUDSScriptFactory : public UFactory +{ + GENERATED_BODY() + +public: + USUDSScriptFactory(); +protected: + virtual UObject* FactoryCreateText(UClass* InClass, + UObject* InParent, + FName InName, + EObjectFlags Flags, + UObject* Context, + const TCHAR* Type, + const TCHAR*& Buffer, + const TCHAR* BufferEnd, + FFeedbackContext* Warn) override; + + bool ShouldGenerateVoiceAssets(const FString& PackagePath) const; + FSUDSScriptImporter Importer; + + void ForceDeleteAssets(const TArray& Assets); + UStringTable* CreateStringTable(UObject* ScriptParent, FName InName, USUDSScript* Script, EObjectFlags Flags, FSUDSMessageLogger* Logger); +}; diff --git a/Plugins/SUDS/Source/SUDSEditor/Public/SUDSScriptImporter.h b/Plugins/SUDS/Source/SUDSEditor/Public/SUDSScriptImporter.h new file mode 100644 index 00000000..60f213a8 --- /dev/null +++ b/Plugins/SUDS/Source/SUDSEditor/Public/SUDSScriptImporter.h @@ -0,0 +1,520 @@ +// Copyright Steve Streeting 2022 +// Released under the MIT license https://opensource.org/license/MIT/ +#pragma once + +#include "CoreMinimal.h" +#include "SUDSExpression.h" + +struct FSUDSMessageLogger; +class USUDSScript; +DECLARE_LOG_CATEGORY_EXTERN(LogSUDSImporter, Verbose, All); + +struct SUDSEDITOR_API FSUDSParsedEdge +{ +public: + /// Text associated with this edge (if a player choice option) + FString Text; + /// Identifier of the text, for the string table + FString TextID; + /// Metadata associated with text, for translator comments + TMap TextMetadata; + /// The line this edge was created on + int SourceLineNo; + /// Condition expression that applies to this edge (for select nodes) + FSUDSExpression ConditionExpression; + /// Custom metadata for a node which the user can set. Can be used to annotate choice lines + /// Could be used to disable a choice if you don't have the stats to take it, or anything else + /// where you need a bit of extra information about a line that doesn't suit setting a dialogue variable for. + TMap UserMetadata; + + int SourceNodeIdx = -1; + int TargetNodeIdx = -1; + + FSUDSParsedEdge(int LineNo) : SourceLineNo(LineNo){} + + FSUDSParsedEdge(int FromNodeIdx, int ToNodeIdx, int LineNo, const FString& InText, const FString& InTextID, const TMap& Metadata, const TMap& UserMeta) + : Text(InText), + TextID(InTextID), + TextMetadata(Metadata), + SourceLineNo(LineNo), + UserMetadata(UserMeta), + SourceNodeIdx(FromNodeIdx), + TargetNodeIdx(ToNodeIdx) + { + } + + FSUDSParsedEdge(int FromNodeIdx, int ToNodeIdx, int LineNo) + : SourceLineNo(LineNo), + SourceNodeIdx(FromNodeIdx), + TargetNodeIdx(ToNodeIdx) + + { + } + + void Reset() + { + *this = FSUDSParsedEdge(-1); + } + +}; + +enum class ESUDSParsedNodeType : uint8 +{ + /// Text node, displaying a line of dialogue + Text, + /// Choice node, displaying a series of user choices which navigate to other nodes + Choice, + /// Select node, automatically selecting one which navigates to another node based on state (also Random) + Select, + /// Goto node, redirects execution somewhere else + /// Gotos are only nodes in the parsing structure, because they need to be discoverable as a fallthrough destination + /// When converting to runtime use they just become edges + Goto, + /// Gosub node, acts like goto except stores return location + Gosub, + /// Return node, returns from a gosub + Return, + /// Set variable node + SetVariable, + /// Event node + Event +}; + +/// Intermediate parsed node from script text +/// This will be converted into a final asset later +struct SUDSEDITOR_API FSUDSParsedNode +{ +public: + ESUDSParsedNodeType NodeType; + int OriginalIndent; + /// Identifier is speaker ID, goto label, variable name etc + FString Identifier; + /// Text in native language + FString Text; + /// Identifier of the text, for the string table + FString TextID; + /// Metadata associated with text, for translator comments + TMap TextMetadata; + /// Expression, for nodes that use it (e.g. set) + FSUDSExpression Expression; + /// Event arguments, for event nodes + TArray EventArgs; + /// Labels which lead to this node + TArray Labels; + /// Edges leading to other nodes + TArray Edges; + /// The line this node was created on + int SourceLineNo; + /// Whether this is a valid fall-through target + bool AllowFallthrough = true; + + /// Custom metadata for a node which the user can set. Can be used to annotate speaker lines + TMap UserMetadata; + + // Path hierarchy of choice nodes leading to this node, of the form "/C002/C006" etc, not including this node index + // This helps us identify valid fallthroughs + FString ChoicePath; + // Path hierarchy of select nodes leading to this node, of the form "/S002/S006" etc, not including this node index + // This helps us identify valid fallthroughs + FString ConditionalPath; + + /// Although multiple edges can lead here, this index is for the auto-connected parent (may be nothing) + int ParentNodeIdx = -1; + + FSUDSParsedNode(ESUDSParsedNodeType InNodeType, int Indent, int LineNo) : NodeType(InNodeType), + OriginalIndent(Indent), + SourceLineNo(LineNo) + { + } + + FSUDSParsedNode(const FString& Label, + const FString& GosubID, + int Indent, + int LineNo) : NodeType(ESUDSParsedNodeType::Gosub), + OriginalIndent(Indent), + Identifier(Label), + TextID(GosubID), + SourceLineNo(LineNo) + { + } + + FSUDSParsedNode(const FString& InSpeaker, const FString& InText, const FString& InTextID, const TMap& Metadata, const TMap& UserMeta, int Indent, int LineNo) + : NodeType(ESUDSParsedNodeType::Text), + OriginalIndent(Indent), + Identifier(InSpeaker), + Text(InText), + TextID(InTextID), + TextMetadata(Metadata), + SourceLineNo(LineNo), + UserMetadata(UserMeta) + { + } + + FSUDSParsedNode(const FString& GotoLabel, int Indent, int LineNo) + : NodeType(ESUDSParsedNodeType::Goto), OriginalIndent(Indent), Identifier(GotoLabel), SourceLineNo(LineNo) + { + } + + FSUDSParsedNode(const FString& VariableName, const FSUDSExpression& InExpr, int Indent, int LineNo) + : NodeType(ESUDSParsedNodeType::SetVariable), + OriginalIndent(Indent), + Identifier(VariableName), + Expression(InExpr), + SourceLineNo(LineNo) + { + } + + FSUDSParsedNode(const FString& VariableName, + const FSUDSExpression& InExpr, + const FString& InTextID, + int Indent, + int LineNo) + : NodeType(ESUDSParsedNodeType::SetVariable), + OriginalIndent(Indent), + Identifier(VariableName), + TextID(InTextID), + Expression(InExpr), + SourceLineNo(LineNo) + { + } +}; +class SUDSEDITOR_API FSUDSScriptImporter +{ +public: + bool ImportFromBuffer(const TCHAR* Buffer, int32 Len, const FString& NameForErrors, FSUDSMessageLogger* Logger, bool bSilent); + void PopulateAsset(USUDSScript* Asset, UStringTable* StringTable); + static FMD5Hash CalculateHash(const TCHAR* Buffer, int32 Len); + static const FString EndGotoLabel; +protected: + static const FString TreePathSeparator; + + enum class EConditionalStage : uint8 + { + IfStage, + ElseIfStage, + ElseStage, + RandomStage, + RandomOptionStage + }; + enum class EConditionalParent : uint8 + { + Select, + ElseIfStage, + ElseStage + }; + + // Struct for tracking if/elseif blocks + struct ConditionalContext + { + /// Index of parent select node where else will be added + int SelectNodeIdx = -1; + /// Previous block index (parent for nesting) + int PreviousBlockIdx = -1; + /// Track whether we're in if/elseif/else + EConditionalStage Stage; + /// String identifying the current condition; for elseif or else contains original "if" context + FString ConditionPathElement; + + ConditionalContext(int InSelectNodeIdx, int InPrevBlockIdx, EConditionalStage InStage, const FString& InCondStr) : + SelectNodeIdx(InSelectNodeIdx), + PreviousBlockIdx(InPrevBlockIdx), + Stage(InStage), + ConditionPathElement(InCondStr) + { + } + + }; + + /// Struct for tracking indents + struct IndentContext + { + public: + // The index of the Node which is the parent of this context + // This potentially changes every time a sequential text node is encountered in the same context, so it's + // always pointing to the last node encountered at this level, for connection + int LastNodeIdx = -1; + + /// The outermost indent level where this context lives + /// You can indent things that don't create a new context, e.g. + /// 1. Indent a text line under another text line: this is the same as no indent, just a continuation + /// 2. Indent choices or conditions under a text line + /// This is just good for readability, but does not create a new context, it's just a linear sequence + /// Therefore the ThresholdIndent tracks the outermost indent relating to the current linear sequence, to know + /// when you do in fact need to pop the current context off the stack. + int ThresholdIndent = 0; + + int LastTextNodeIdx = -1; + + /// The path entry for this indent, to be combined with all previous levels to provide full path context + FString PathEntry; + + IndentContext(int NodeIdx, int Indent, const FString& Path) : LastNodeIdx(NodeIdx), ThresholdIndent(Indent), LastTextNodeIdx(-1), PathEntry(Path) {} + + }; + + /// A tree of nodes. Contained to separate header nodes from body nodes + struct ParsedTree + { + public: + /// The indent context stack representing where we are in the indentation tree while parsing + /// There must always be 1 level (root) + TArray IndentLevelStack; + /// When encountering conditions and choice lines, we are building up details for an edge to another node, but + /// we currently don't know the target node. We keep these pending details here + int EdgeInProgressNodeIdx = -1; + int EdgeInProgressEdgeIdx = -1; + /// List of all nodes, appended to as parsing progresses + /// Ordering is important, these nodes must be in the order encountered in the file + TArray Nodes; + /// Record of goto labels to node index, built up during parsing (forward refs are OK so not complete until end of parsing) + TMap GotoLabelList; + /// Goto labels which have been encountered but we haven't found a destination yet + TArray PendingGotoLabels; + /// Goto labels that lead directly to another goto and thus are just aliases + TMap AliasedGotoLabels; + + /// Conditional blocks + /// "if" creates a new context, uses current as parent + /// "elseif" and "else" also create new contexts, but copies parent from current (sibling) + /// "endif" ends the context + /// You cannot have conditionals that were started in an indent context ending outside it + TArray ConditionalBlocks; + /// Index of the current conditional block, if any + int CurrentConditionalBlockIdx = -1; + + void Reset() + { + IndentLevelStack.Reset(); + EdgeInProgressNodeIdx = EdgeInProgressEdgeIdx -1; + Nodes.Reset(); + GotoLabelList.Reset(); + PendingGotoLabels.Reset(); + AliasedGotoLabels.Reset(); + ConditionalBlocks.Reset(); + CurrentConditionalBlockIdx = -1; + } + }; + + ParsedTree HeaderTree; + ParsedTree BodyTree; + + struct ParsedMetadata + { + public: + FName Key; + FString Value; + int IndentLevel; + + ParsedMetadata(const FName& InKey, const FString& InValue, int InIndentLevel) + : Key(InKey), + Value(InValue), + IndentLevel(InIndentLevel) + { + } + }; + + /// Text Metadata applied to speaker lines / choices until reset + /// For each key there's a stack of metadata, with most indented at the top + TMap> PersistentMetadata; + /// Text Metadata applied just to the next speaker line or choice + TMap TransientMetadata; + /// User metadata for the next speaker line or choice + TMap UserMetadata; + + /// List of speakers, detected during parsing of lines of text + TArray ReferencedSpeakers; + + const int TabIndentValue = 4; + bool bHeaderDone = false; + bool bTooLateForHeader = false; + bool bHeaderInProgress = false; + TOptional bOverrideGenerateSpeakerLineForChoice; + TOptional OverrideChoiceSpeakerID; + bool bTextInProgress = false; + int ChoiceUniqueId = 0; + /// For generating text IDs + int TextIDHighestNumber = 0; + /// For generating gosub IDs + int GosubIDHighestNumber = 0; + /// Parse a single line + bool ParseLine(const FStringView& Line, int LineNo, const FString& NameForErrors, FSUDSMessageLogger* Logger, bool bSilent); + bool ParseHeaderLine(const FStringView& Line, int IndentLevel, int LineNo, const FString& NameForErrors, FSUDSMessageLogger* Logger, bool bSilent); + bool ParseBodyLine(const FStringView& Line, int IndentLevel, int LineNo, const FString& NameForErrors, FSUDSMessageLogger* Logger, bool bSilent); + bool ParseCommentMetadataLine(const FStringView& Line, int IndentLevel, int LineNo, const FString& NameForErrors, FSUDSMessageLogger* Logger, bool bSilent); + bool IsLastNodeOfType(const ParsedTree& Tree, ESUDSParsedNodeType Type); + bool ParseChoiceLine(const FStringView& Line, ParsedTree& Tree, int IndentLevel, int LineNo, const FString& NameForErrors, FSUDSMessageLogger* + Logger, + bool bSilent); + FSUDSParsedEdge* GetEdgeInProgress(ParsedTree& Tree); + void EnsureChoiceNodeExistsAboveSelect(ParsedTree& Tree, int IndentLevel, int LineNo); + static bool IsConditionalLine(const FStringView& Line); + bool ParseConditionalLine(const FStringView& Line, ParsedTree& Tree, int IndentLevel, int LineNo, const FString& NameForErrors, FSUDSMessageLogger* Logger, bool bSilent); + bool ParseIfLine(const FStringView& Line, + ParsedTree& Tree, + const FString& ConditionStr, + int IndentLevel, + int LineNo, + const FString& NameForErrors, + FSUDSMessageLogger* Logger, + bool bSilent); + bool ParseElseIfLine(const FStringView& Line, + ParsedTree& Tree, + const FString& ConditionStr, + int IndentLevel, + int LineNo, + const FString& NameForErrors, + FSUDSMessageLogger* Logger, + bool bSilent); + + bool ParseElseLine(const FStringView& Line, + ParsedTree& Tree, + int IndentLevel, + int LineNo, + const FString& NameForErrors, + FSUDSMessageLogger* Logger, + bool bSilent); + bool ParseEndIfLine(const FStringView& Line, + ParsedTree& Tree, + int IndentLevel, + int LineNo, + const FString& NameForErrors, + FSUDSMessageLogger* + Logger, + bool bSilent); + static bool IsRandomLine(const FStringView& Line); + bool ParseRandomLine(const FStringView& Line, + ParsedTree& Tree, + int IndentLevel, + int LineNo, + const FString& NameForErrors, + FSUDSMessageLogger* Logger, + bool bSilent); + bool ParseBeginRandomLine(const FStringView& Line, + ParsedTree& Tree, + int IndentLevel, + int LineNo, + const FString& NameForErrors, + FSUDSMessageLogger* Logger, + bool bSilent); + bool ParseRandomOptionLine(const FStringView& Line, + ParsedTree& Tree, + int IndentLevel, + int LineNo, + const FString& NameForErrors, + FSUDSMessageLogger* Logger, + bool bSilent); + bool ParseEndRandomLine(const FStringView& Line, + ParsedTree& Tree, + int IndentLevel, + int LineNo, + const FString& NameForErrors, + FSUDSMessageLogger* Logger, + bool bSilent); + bool ParseGotoLabelLine(const FStringView& Line, + ParsedTree& Tree, + int IndentLevel, + int LineNo, + const FString& NameForErrors, + FSUDSMessageLogger* Logger, + bool bSilent); + bool ParseGotoLine(const FStringView& Line, + ParsedTree& Tree, + int IndentLevel, + int LineNo, + const FString& NameForErrors, + FSUDSMessageLogger* Logger, + bool bSilent); + bool ParseGosubLine(const FStringView& Line, + ParsedTree& Tree, + int IndentLevel, + int LineNo, + const FString& NameForErrors, + FSUDSMessageLogger* Logger, + bool bSilent); + bool ParseReturnLine(const FStringView& Line, + ParsedTree& Tree, + int IndentLevel, + int LineNo, + const FString& NameForErrors, + FSUDSMessageLogger* Logger, + bool bSilent); + bool ParseSetLine(const FStringView& Line, + ParsedTree& Tree, + int IndentLevel, + int LineNo, + const FString& NameForErrors, + FSUDSMessageLogger* Logger, + bool bSilent); + bool ParseEventLine(const FStringView& Line, + ParsedTree& Tree, + int IndentLevel, + int LineNo, + const FString& NameForErrors, + FSUDSMessageLogger* Logger, + bool bSilent); + bool ParseTextLine(const FStringView& Line, + ParsedTree& Tree, + int IndentLevel, + int LineNo, + const FString& NameForErrors, + FSUDSMessageLogger* Logger, + bool bSilent); + bool ParseImportSettingLine(const FStringView& Line, + ParsedTree& Tree, + int IndentLevel, + int LineNo, + const FString& NameForErrors, + FSUDSMessageLogger* Logger, + bool bSilent); + TMap GetTextMetadataForNextEntry(int CurrentLineIndent); + TMap ConsumeUserMetadata(); + bool IsCommentLine(const FStringView& TrimmedLine); + FStringView TrimLine(const FStringView& Line, int& OutIndentLevel) const; + int FindChoiceAfterTextNode(const FSUDSScriptImporter::ParsedTree& Tree, int TextNodeIdx, const FString& ConditionalPath); + int RecurseChoiceNodeFindDeepest(const ParsedTree& Tree, int FromChoiceIdx, const FString& ConditionalPath); + int RecurseSelectNodeFindDeepestChoice(const ParsedTree& Tree, int FromSelectNode, const FString& ConditionalPath); + int FindEdge(const FSUDSScriptImporter::ParsedTree& Tree, int ParentNodeIdx, int TargetNodeIndex); + FString MakeIfConditionPathElement(int SelectNodeIdx, const FString& ConditionStr); + FString MakeElseIfConditionPathElement(int SelectNodeIdx, const FString& ConditionStr); + FString MakeElseConditionPathElement(int SelectNodeIdx); + int FindLastChoiceNode(const ParsedTree& Tree, int IndentLevel); + int FindLastChoiceNode(const ParsedTree& Tree, int IndentLevel, int FromIndex, const FString& ConditionPath); + void PopIndent(ParsedTree& Tree); + void PushIndent(ParsedTree& Tree, int NodeIdx, int Indent, const FString& Path); + FString GetCurrentTreePath(const FSUDSScriptImporter::ParsedTree& Tree); + FString GetCurrentTreeConditionalPath(const FSUDSScriptImporter::ParsedTree& Tree); + FString GetTreeConditionalPath(const ParsedTree& Tree, int NodeIndex); + void SetFallthroughForNewNode(FSUDSScriptImporter::ParsedTree& Tree, FSUDSParsedNode& NewNode); + int AppendNode(ParsedTree& Tree, const FSUDSParsedNode& InNode); + bool SelectNodeIsMissingElsePath(const FSUDSScriptImporter::ParsedTree& Tree, const FSUDSParsedNode& Node); + bool PostImportSanityCheck(const FString& NameForErrors, FSUDSMessageLogger* Logger, bool bSilent); + bool ChoiceNodeCheckPaths(const FSUDSParsedNode& ChoiceNode, + const FString& NameForErrors, + FSUDSMessageLogger* Logger, + bool bSilent); + bool RecurseChoiceNodeCheckPaths(const FSUDSParsedNode& OrigChoiceNode, const FSUDSParsedNode& CurrChoiceNode, const FString& NameForErrors, FSUDSMessageLogger* Logger, bool bSilent); + bool RecurseChoiceNodeCheckPaths(const FSUDSParsedNode& ChoiceNode, const FSUDSParsedEdge& Edge, const FString& NameForErrors, FSUDSMessageLogger* + Logger, + bool bSilent); + void ConnectRemainingNodes(ParsedTree& Tree, const FString& NameForErrors, FSUDSMessageLogger* Logger, bool bSilent); + void GenerateTextIDs(ParsedTree& BodyTree); + int FindFallthroughNodeIndex(ParsedTree& Tree, int StartNodeIndex, const FString& FromChoicePath, const FString& FromConditionalPath); + bool RetrieveAndRemoveTextID(FStringView& InOutLine, FString& OutTextID); + bool RetrieveAndRemoveGosubID(FStringView& InOutLine, FString& OutTextID); + FString GenerateTextID(); + const FSUDSParsedNode* GetNode(const ParsedTree& Tree, int Index = 0); + int GetGotoTargetNodeIndex(const ParsedTree& Tree, const FString& InLabel); + void PopulateAssetFromTree(USUDSScript* Asset, + const ParsedTree& Tree, + TArray>* pOutNodes, + TMap* pOutLabels, + UStringTable* StringTable); + +public: + const FSUDSParsedNode* GetNode(int Index = 0); + const FSUDSParsedNode* GetHeaderNode(int Index = 0); + /// Resolve a goto label to a target index (after import), or -1 if not resolvable + int GetGotoTargetNodeIndex(const FString& Label); + static bool RetrieveTextIDFromLine(FStringView& InOutLine, FString& OutTextID, int& OutNumber); + static bool RetrieveGosubIDFromLine(FStringView& InOutLine, FString& OutID, int& OutNumber); +}; diff --git a/Plugins/SUDS/Source/SUDSEditor/Public/SUDSScriptReimportFactory.h b/Plugins/SUDS/Source/SUDSEditor/Public/SUDSScriptReimportFactory.h new file mode 100644 index 00000000..b9924991 --- /dev/null +++ b/Plugins/SUDS/Source/SUDSEditor/Public/SUDSScriptReimportFactory.h @@ -0,0 +1,27 @@ +// Copyright Steve Streeting 2022 +// Released under the MIT license https://opensource.org/license/MIT/ +#pragma once + +#include "CoreMinimal.h" +#include "UObject/ObjectMacros.h" +#include "EditorReimportHandler.h" +#include "SUDSScriptFactory.h" + +#include "SUDSScriptReimportFactory.generated.h" + +// Reimports a USUDSScriptFactory asset +// Necessary to get the import pop-up +UCLASS() +class USUDSScriptReimportFactory : public USUDSScriptFactory, public FReimportHandler +{ + GENERATED_BODY() +public: + USUDSScriptReimportFactory(); + + //~ Begin FReimportHandler Interface + virtual bool CanReimport(UObject* Obj, TArray& OutFilenames) override; + virtual void SetReimportPaths(UObject* Obj, const TArray& NewReimportPaths) override; + virtual EReimportResult::Type Reimport(UObject* Obj) override; + virtual int32 GetPriority() const override; + //~ End FReimportHandler Interface +}; \ No newline at end of file diff --git a/Plugins/SUDS/Source/SUDSEditor/SUDSEditor.Build.cs b/Plugins/SUDS/Source/SUDSEditor/SUDSEditor.Build.cs new file mode 100644 index 00000000..04424f67 --- /dev/null +++ b/Plugins/SUDS/Source/SUDSEditor/SUDSEditor.Build.cs @@ -0,0 +1,62 @@ +// Copyright Steve Streeting 2022 +// Released under the MIT license https://opensource.org/license/MIT/ +using UnrealBuildTool; + +public class SUDSEditor : ModuleRules +{ + public SUDSEditor(ReadOnlyTargetRules Target) : base(Target) + { + PCHUsage = ModuleRules.PCHUsageMode.UseExplicitOrSharedPCHs; + + PublicIncludePaths.AddRange( + new string[] { + // ... add public include paths required here ... + } + ); + + + PrivateIncludePaths.AddRange( + new string[] { + // ... add other private include paths required here ... + } + ); + + + PublicDependencyModuleNames.AddRange( + new string[] + { + "Core", + "SUDS" + // ... add other public dependencies that you statically link with here ... + } + ); + + + PrivateDependencyModuleNames.AddRange( + new string[] + { + "CoreUObject", + "Engine", + "Slate", + "SlateCore", + "InputCore", // needed by come Slate widgets + "Projects", // So that we can use the IPluginManager, required for our custom style + "ToolMenus", + "MessageLog", + "UnrealEd", + "EditorStyle" + // ... add private dependencies that you statically link with here ... + } + ); + + + DynamicallyLoadedModuleNames.AddRange( + new string[] + { + // ... add any modules that your module loads dynamically here ... + } + ); + + } + +} diff --git a/Plugins/SUDS/Source/SUDSTest/Private/SudsTestModule.cpp b/Plugins/SUDS/Source/SUDSTest/Private/SudsTestModule.cpp new file mode 100644 index 00000000..9a934491 --- /dev/null +++ b/Plugins/SUDS/Source/SUDSTest/Private/SudsTestModule.cpp @@ -0,0 +1,21 @@ +#include "SudsTestModule.h" + +#include "Modules/ModuleManager.h" + +DEFINE_LOG_CATEGORY(LogSudsTestModule) + +void FSudsTestModule::StartupModule() +{ + // This code will execute after your module is loaded into memory; the exact timing is specified in the .uplugin file per-module + UE_LOG(LogSudsTestModule, Log, TEXT("SUDSTest Module Started")) +} + +void FSudsTestModule::ShutdownModule() +{ + // This function may be called during shutdown to clean up your module. For modules that support dynamic reloading, + // we call this function before unloading the module. + UE_LOG(LogSudsTestModule, Log, TEXT("SUDSTest Module Stopped")) +} + + +IMPLEMENT_MODULE(FSudsTestModule, SUDSTest) diff --git a/Plugins/SUDS/Source/SUDSTest/Private/SudsTestModule.h b/Plugins/SUDS/Source/SUDSTest/Private/SudsTestModule.h new file mode 100644 index 00000000..870cc5a6 --- /dev/null +++ b/Plugins/SUDS/Source/SUDSTest/Private/SudsTestModule.h @@ -0,0 +1,14 @@ +#pragma once + +#include "CoreMinimal.h" + +#include "Modules/ModuleInterface.h" + +DECLARE_LOG_CATEGORY_EXTERN(LogSudsTestModule, All, All) + +class FSudsTestModule : public IModuleInterface +{ +public: + virtual void StartupModule() override; + virtual void ShutdownModule() override; +}; \ No newline at end of file diff --git a/Plugins/SUDS/Source/SUDSTest/Private/TestChoiceSpeakerLines.cpp b/Plugins/SUDS/Source/SUDSTest/Private/TestChoiceSpeakerLines.cpp new file mode 100644 index 00000000..4c32be66 --- /dev/null +++ b/Plugins/SUDS/Source/SUDSTest/Private/TestChoiceSpeakerLines.cpp @@ -0,0 +1,274 @@ +#include "SUDSDialogue.h" +#include "SUDSLibrary.h" +#include "SUDSMessageLogger.h" +#include "SUDSScript.h" +#include "SUDSScriptImporter.h" +#include "TestParticipant.h" +#include "TestUtils.h" +#include "Misc/AutomationTest.h" + +UE_DISABLE_OPTIMIZATION + +const FString ChoicesAsSpeakerLineEnableInScriptInput = R"RAWSUD( +=== +[importsetting GenerateSpeakerLinesFromChoices true] +=== +Player: Hello + * Choice 1 + NPC: I see + * Choice 1a + NPC: Sure + *- Not a speaker line! + Player: I had to say this separately + * Choice 2 + NPC: Totally + +Player: The end +)RAWSUD"; + + + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestChoiceSpeakerLineInScript, + "SUDSTest.TestChoiceSpeakerLineInScript", + EAutomationTestFlags::EditorContext | + EAutomationTestFlags::ClientContext | + EAutomationTestFlags::ProductFilter) + + + +bool FTestChoiceSpeakerLineInScript::RunTest(const FString& Parameters) +{ + FSUDSMessageLogger Logger(false); + FSUDSScriptImporter Importer; + TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(ChoicesAsSpeakerLineEnableInScriptInput), ChoicesAsSpeakerLineEnableInScriptInput.Len(), "ChoicesAsSpeakerLineEnableInScriptInput", &Logger, true)); + + auto Script = NewObject(GetTransientPackage(), "Test"); + const ScopedStringTableHolder StringTableHolder; + Importer.PopulateAsset(Script, StringTableHolder.StringTable); + + // Script shouldn't be the owner of the dialogue but it's the only UObject we've got right now so why not + auto Dlg = USUDSLibrary::CreateDialogue(Script, Script); + auto Participant = NewObject(); + Participant->TestNumber = 0; + Dlg->AddParticipant(Participant); + Dlg->Start(); + + TestDialogueText(this, "Line 1", Dlg, "Player", "Hello"); + TestEqual("Choice text", Dlg->GetChoiceText(0).ToString(), TEXT("Choice 1")); + TestEqual("Choice text", Dlg->GetChoiceText(1).ToString(), TEXT("Choice 2")); + const FText ChoiceText0 = Dlg->GetChoiceText(0); + Dlg->Choose(0); + // Confirm that we got the choice as a speaker line + TestDialogueText(this, "Choice as speaker line 1", Dlg, "Player", "Choice 1"); + // Confirm that text ID is the same + TestTrue("Text should be identical", ChoiceText0.IdenticalTo(Dlg->GetText(), ETextIdenticalModeFlags::DeepCompare)); + TestEqual("TextIDs should match", SUDS_GET_TEXT_KEY(ChoiceText0), SUDS_GET_TEXT_KEY(Dlg->GetText())); + + Dlg->Continue(); + TestDialogueText(this, "Regular speaker line", Dlg, "NPC", "I see"); + TestEqual("Choice text", Dlg->GetChoiceText(0).ToString(), TEXT("Choice 1a")); + TestEqual("Choice text", Dlg->GetChoiceText(1).ToString(), TEXT("Not a speaker line!")); + + // Test choice which disables speaker line + Dlg->Choose(1); + TestDialogueText(this, "Regular speaker line", Dlg, "Player", "I had to say this separately"); + + + Script->MarkAsGarbage(); + return true; +} + + +const FString ChoicesAsSpeakerLineSetSpeakerIdInScriptInput = R"RAWSUD( +=== +[importsetting GenerateSpeakerLinesFromChoices true] +[importsetting SpeakerIDForGeneratedLinesFromChoices `TheDude`] +=== +Player: Hello + * Choice 1 + NPC: You said that choice + * Choice 2 + NPC: Also that one +Player: The end +)RAWSUD"; + + + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestChoiceSpeakerSetSpeakerIdInScriptInput, + "SUDSTest.TestChoiceSpeakerSetSpeakerIdInScriptInput", + EAutomationTestFlags::EditorContext | + EAutomationTestFlags::ClientContext | + EAutomationTestFlags::ProductFilter) + + + +bool FTestChoiceSpeakerSetSpeakerIdInScriptInput::RunTest(const FString& Parameters) +{ + FSUDSMessageLogger Logger(false); + FSUDSScriptImporter Importer; + TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(ChoicesAsSpeakerLineSetSpeakerIdInScriptInput), ChoicesAsSpeakerLineSetSpeakerIdInScriptInput.Len(), "ChoicesAsSpeakerLineSetSpeakerIdInScriptInput", &Logger, true)); + + auto Script = NewObject(GetTransientPackage(), "Test"); + const ScopedStringTableHolder StringTableHolder; + Importer.PopulateAsset(Script, StringTableHolder.StringTable); + + // Script shouldn't be the owner of the dialogue but it's the only UObject we've got right now so why not + auto Dlg = USUDSLibrary::CreateDialogue(Script, Script); + auto Participant = NewObject(); + Participant->TestNumber = 0; + Dlg->AddParticipant(Participant); + Dlg->Start(); + + TestDialogueText(this, "Line 1", Dlg, "Player", "Hello"); + TestEqual("Choice text", Dlg->GetChoiceText(0).ToString(), TEXT("Choice 1")); + TestEqual("Choice text", Dlg->GetChoiceText(1).ToString(), TEXT("Choice 2")); + const FText ChoiceText0 = Dlg->GetChoiceText(0); + Dlg->Choose(0); + // Confirm that text ID is the same + TestTrue("Text should be identical", ChoiceText0.IdenticalTo(Dlg->GetText(), ETextIdenticalModeFlags::DeepCompare)); + TestEqual("TextIDs should match", SUDS_GET_TEXT_KEY(ChoiceText0), SUDS_GET_TEXT_KEY(Dlg->GetText())); + + // Confirm that we got the choice as a speaker line, with custom speaker ID + TestDialogueText(this, "Choice as speaker line 1", Dlg, "TheDude", "Choice 1"); + Dlg->Continue(); + TestDialogueText(this, "Regular speaker line", Dlg, "NPC", "You said that choice"); + + Script->MarkAsGarbage(); + return true; +} + +const FString ChoicesAsSpeakerLineChainChoiceInput = R"RAWSUD( +=== +[importsetting GenerateSpeakerLinesFromChoices true] +=== +Player: Hello + * Choice 1 + * Choice 1a + NPC: Hey this worked + * Choice 1b + * Choice 2 + NPC: Also that one +Player: The end +)RAWSUD"; + + + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestChoiceSpeakerChainedChoiceInput, + "SUDSTest.TestChoiceSpeakerChainedChoiceInput", + EAutomationTestFlags::EditorContext | + EAutomationTestFlags::ClientContext | + EAutomationTestFlags::ProductFilter) + + + +bool FTestChoiceSpeakerChainedChoiceInput::RunTest(const FString& Parameters) +{ + FSUDSMessageLogger Logger(false); + FSUDSScriptImporter Importer; + TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(ChoicesAsSpeakerLineChainChoiceInput), ChoicesAsSpeakerLineChainChoiceInput.Len(), "ChoicesAsSpeakerLineChainChoiceInput", &Logger, true)); + + auto Script = NewObject(GetTransientPackage(), "Test"); + const ScopedStringTableHolder StringTableHolder; + Importer.PopulateAsset(Script, StringTableHolder.StringTable); + + // Script shouldn't be the owner of the dialogue but it's the only UObject we've got right now so why not + auto Dlg = USUDSLibrary::CreateDialogue(Script, Script); + auto Participant = NewObject(); + Participant->TestNumber = 0; + Dlg->AddParticipant(Participant); + Dlg->Start(); + + TestDialogueText(this, "Line 1", Dlg, "Player", "Hello"); + TestEqual("Choice text", Dlg->GetChoiceText(0).ToString(), TEXT("Choice 1")); + TestEqual("Choice text", Dlg->GetChoiceText(1).ToString(), TEXT("Choice 2")); + Dlg->Choose(0); + // Confirm that we got the choice as a speaker line, with custom speaker ID + TestDialogueText(this, "Choice as speaker line 1", Dlg, "Player", "Choice 1"); + TestEqual("Choice 2nd level text", Dlg->GetChoiceText(0).ToString(), TEXT("Choice 1a")); + TestEqual("Choice 2nd leveltext", Dlg->GetChoiceText(1).ToString(), TEXT("Choice 1b")); + Dlg->Choose(0); + TestDialogueText(this, "Choice as speaker line 1", Dlg, "Player", "Choice 1a"); + Dlg->Continue(); + TestDialogueText(this, "Regular speaker line", Dlg, "NPC", "Hey this worked"); + + Script->MarkAsGarbage(); + return true; +} + +const FString ChoicesAsSpeakerLineWithStringKeysInput = R"RAWSUD( +=== +[importsetting GenerateSpeakerLinesFromChoices true] +=== +Player: Hello @0012@ + * Choice 1 @0016@ + NPC: I see @0017@ + * Choice 1a @0018@ + NPC: Sure + *- Not a speaker line! + Player: I had to say this separately + * Choice 2 + NPC: Totally + +Player: The end +)RAWSUD"; + + + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestChoicesAsSpeakerLineWithStringKeys, + "SUDSTest.TestChoicesAsSpeakerLineWithStringKeys", + EAutomationTestFlags::EditorContext | + EAutomationTestFlags::ClientContext | + EAutomationTestFlags::ProductFilter) + + + +bool FTestChoicesAsSpeakerLineWithStringKeys::RunTest(const FString& Parameters) +{ + FSUDSMessageLogger Logger(false); + FSUDSScriptImporter Importer; + TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(ChoicesAsSpeakerLineWithStringKeysInput), ChoicesAsSpeakerLineWithStringKeysInput.Len(), "ChoicesAsSpeakerLineWithStringKeysInput", &Logger, true)); + + auto Script = NewObject(GetTransientPackage(), "Test"); + const ScopedStringTableHolder StringTableHolder; + Importer.PopulateAsset(Script, StringTableHolder.StringTable); + + // Script shouldn't be the owner of the dialogue but it's the only UObject we've got right now so why not + auto Dlg = USUDSLibrary::CreateDialogue(Script, Script); + auto Participant = NewObject(); + Participant->TestNumber = 0; + Dlg->AddParticipant(Participant); + Dlg->Start(); + + TestDialogueText(this, "Line 1", Dlg, "Player", "Hello"); + TestEqual("String key", SUDS_GET_TEXT_KEY(Dlg->GetText()), "@0012@"); + TestEqual("Choice text", Dlg->GetChoiceText(0).ToString(), TEXT("Choice 1")); + TestEqual("Choice text", Dlg->GetChoiceText(1).ToString(), TEXT("Choice 2")); + TestEqual("String key", SUDS_GET_TEXT_KEY(Dlg->GetChoiceText(0)), "@0016@"); + FText ChoiceText0 = Dlg->GetChoiceText(0); + Dlg->Choose(0); + // Confirm that we got the choice as a speaker line + TestDialogueText(this, "Choice as speaker line 1", Dlg, "Player", "Choice 1"); + // Confirm that text ID is the same + TestTrue("Text should be identical", ChoiceText0.IdenticalTo(Dlg->GetText(), ETextIdenticalModeFlags::DeepCompare)); + TestEqual("TextIDs should match", SUDS_GET_TEXT_KEY(ChoiceText0), SUDS_GET_TEXT_KEY(Dlg->GetText())); + + Dlg->Continue(); + TestDialogueText(this, "Regular speaker line", Dlg, "NPC", "I see"); + TestEqual("String key", SUDS_GET_TEXT_KEY(Dlg->GetText()), "@0017@"); + TestEqual("String key", SUDS_GET_TEXT_KEY(Dlg->GetChoiceText(0)), "@0018@"); + TestEqual("Choice text", Dlg->GetChoiceText(0).ToString(), TEXT("Choice 1a")); + TestEqual("Choice text", Dlg->GetChoiceText(1).ToString(), TEXT("Not a speaker line!")); + + ChoiceText0 = Dlg->GetChoiceText(0); + Dlg->Choose(0); + TestDialogueText(this, "Duplicated speaker line", Dlg, "Player", "Choice 1a"); + + // Confirm that text ID is the same + TestTrue("Text should be identical", ChoiceText0.IdenticalTo(Dlg->GetText(), ETextIdenticalModeFlags::DeepCompare)); + TestEqual("TextIDs should match", SUDS_GET_TEXT_KEY(ChoiceText0), SUDS_GET_TEXT_KEY(Dlg->GetText())); + + Script->MarkAsGarbage(); + return true; +} + +UE_ENABLE_OPTIMIZATION \ No newline at end of file diff --git a/Plugins/SUDS/Source/SUDSTest/Private/TestConditionals.cpp b/Plugins/SUDS/Source/SUDSTest/Private/TestConditionals.cpp new file mode 100644 index 00000000..8ab76500 --- /dev/null +++ b/Plugins/SUDS/Source/SUDSTest/Private/TestConditionals.cpp @@ -0,0 +1,1045 @@ +#include "SUDSLibrary.h" +#include "SUDSMessageLogger.h" +#include "SUDSScript.h" +#include "SUDSScriptImporter.h" +#include "TestUtils.h" + +UE_DISABLE_OPTIMIZATION + +const FString BasicConditionalInput = R"RAWSUD( +Player: Hello +[if {x} == 1] + NPC: Reply when x == 1 + [if {y} > 0] + Player: Player text when x ==1 and y > 0 + [endif] +[elseif {x} > 1] + NPC: Reply when x > 1 +[else] + NPC: Reply when x is something else +[endif] +[if {z}] + Player: the end is true +[else] + Player: the end is false +[endif] +NPC: OK +)RAWSUD"; + +const FString ConditionalChoiceInput = R"RAWSUD( +# First test has a regular choice first before conditional +NPC: Hello + * First choice + Player: I took the 1.1 choice +[if {y} == 2] + * Second choice (conditional) + Player: I took the 1.2 choice + * Third choice (conditional) + Player: I took the 1.3 choice +[else] + * Second Alt Choice + Player: I took the alt 1.2 choice +[endif] + * Common last choice + Player: I took the 1.4 choice +# Second test has conditional choice as the first one +NPC: OK next question +[if {y} == 0] + * First conditional choice + Player: I took the 2.1 choice +[elseif {y} == 1] + * Second conditional choice + Player: I took the 2.2 choice + [if {q} == 10] + * Nested conditional choice + Player: I took the 2.2.1 choice + [endif] +[else] + * Third conditional choice + Player: I took the 2.3 choice +[endif] + * Final common choice + Player: I took the 2.4 choice +NPC: Bye +)RAWSUD"; + +const FString SiblingConditionalChoiceInput = R"RAWSUD( +NPC: Hello +[if {x} == 0] + * First choice + Player: I took the 1.1 choice +[endif] +[if {y} == 1] + * Second choice (conditional) + Player: I took the 1.2 choice +[endif] +[if {z} == 1] + * Third choice (conditional) + Player: I took the 1.3 choice +[endif] + * Second Alt Choice + +NPC: End +)RAWSUD"; + +const FString SiblingConditionalChoiceWithElseInput = R"RAWSUD( +NPC: Hello +[if {x} == 0] + * First choice + Player: I took the 1.1 choice +[elseif {SomeBool}] + * First alt choice elseif + Player: I took the 1.1 elseif choice +[else] + * First alt choice + Player: I took the 1.1b choice +[endif] +[if {y} == 1] + * Second choice (conditional) + Player: I took the 1.2 choice +[elseif {SomeBool}] + * Second alt choice elseif + Player: I took the 1.2 elseif choice +[else] + * Second alt choice + Player: I took the 1.2b choice +[endif] +[if {z} == 1] + * Third choice (conditional) + Player: I took the 1.3 choice +[elseif {SomeBool}] + * Third alt choice elseif + Player: I took the 1.3 elseif choice +[else] + * Third alt choice + Player: I took the 1.3b choice +[endif] +NPC: End +)RAWSUD"; + + +const FString MixedChoiceAndBranchInput = R"RAWSUD( +[if {alreadyvisited}] + [goto secondvisit] +[endif] + +NPC: Hello + * First choice + Player: I took the 1.1 choice +[if {x} > 0] + * Second choice (conditional) + Player: I took the 1.2 choice + * Third choice (conditional) + Player: I took the 1.3 choice +[else] + * Second Alt Choice + Player: I took the alt 1.2 choice +[endif] + * Common last choice + Player: I took the 1.4 choice + +[goto goodbye] + +:secondvisit + +NPC: Hello again + +[if {y} < 0] + Player: Y is less than 0 + NPC: How interesting + * You don't sound that interested + NPC: Well I was trying to be nice + [if {x} == 0] + * Also x is zero + NPC: Fascinating + [endif] + * Well, better be off + [goto goodbye] +[elseif {y} == 0] + Player: Y is zero + NPC: So is my interest level + Player: Rude + +[else] + Player: Who knows what Y is anyway + [if {ponderous}] + * Who knows what anything is? + NPC: Get out + [endif] + [if {y} > 5] + * It's more than I can count on one hand + NPC: Well, that helps + [else] + * It's kind of small though + NPC: Much like your brain + [endif] + * I'm done with this +[endif] + +Player: This is some fallthrough text + +:goodbye +NPC: Bye +)RAWSUD"; + + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestBasicConditionals, + "SUDSTest.TestBasicConditionals", + EAutomationTestFlags::EditorContext | + EAutomationTestFlags::ClientContext | + EAutomationTestFlags::ProductFilter) + + +bool FTestBasicConditionals::RunTest(const FString& Parameters) +{ + FSUDSMessageLogger Logger(false); + FSUDSScriptImporter Importer; + TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(BasicConditionalInput), BasicConditionalInput.Len(), "BasicConditionalInput", &Logger, true)); + + // Test the content of the parsing + auto NextNode = Importer.GetNode(0); + if (!TestNotNull("Root node should exist", NextNode)) + return false; + + TestParsedText(this, "First node", NextNode, "Player", "Hello"); + TestGetParsedNextNode(this, "Get next", NextNode, Importer, false, &NextNode); + + if (TestParsedSelect(this, "First Select node", NextNode, 3)) + { + auto SelectNode = NextNode; + TestParsedSelectEdge(this, "First select edge 1 (if)", SelectNode, 0, "{x} == 1", Importer, &NextNode); + TestParsedText(this, "Nested node 1", NextNode, "NPC", "Reply when x == 1"); + TestGetParsedNextNode(this, "Get next", NextNode, Importer, false, &NextNode); + // Note: even though this is a single "if", there is an implicit "else" edge created + if (TestParsedSelect(this, "Nested Select node", NextNode, 2)) + { + // Nested select + auto SelectNode2 = NextNode; + TestParsedSelectEdge(this, "Nested select edge 1", SelectNode2, 0, "{y} > 0", Importer, &NextNode); + TestParsedText(this, "Nested node edge 1", NextNode, "Player", "Player text when x ==1 and y > 0"); + TestGetParsedNextNode(this, "Get next", NextNode, Importer, false, &NextNode); + if (TestParsedSelect(this, "Fallthrough select", NextNode, 2)) + { + auto SelectNode3 = NextNode; + TestParsedSelectEdge(this, "Final select edge 1", SelectNode3, 0, "{z}", Importer, &NextNode); + TestParsedText(this, "Final select edge 1 text", NextNode, "Player", "the end is true"); + TestGetParsedNextNode(this, "Get next", NextNode, Importer, false, &NextNode); + TestParsedText(this, "Final fallthrough", NextNode, "NPC", "OK"); + + TestParsedSelectEdge(this, "Final select edge 2", SelectNode3, 1, "", Importer, &NextNode); + TestParsedText(this, "Final select edge 2 text", NextNode, "Player", "the end is false"); + TestGetParsedNextNode(this, "Get next", NextNode, Importer, false, &NextNode); + TestParsedText(this, "Final fallthrough", NextNode, "NPC", "OK"); + + } + + // Go back to the nested select + // This "else" edge should have been created automatically to fall through + TestParsedSelectEdge(this, "Nested select edge 2", SelectNode2, 1, "", Importer, &NextNode); + // Just test it gets to the fallthrough, we've already tested the continuation from there + TestParsedSelect(this, "Fallthrough select", NextNode, 2); + + } + TestParsedSelectEdge(this, "First select edge 2 (elseif)", SelectNode, 1, "{x} > 1", Importer, &NextNode); + TestParsedText(this, "Select node 2", NextNode, "NPC", "Reply when x > 1"); + TestGetParsedNextNode(this, "Get next", NextNode, Importer, false, &NextNode); + // Just test it gets to the fallthrough, we've already tested the continuation from there + TestParsedSelect(this, "Fallthrough select", NextNode, 2); + + TestParsedSelectEdge(this, "First select edge 2 (else)", SelectNode, 2, "", Importer, &NextNode); + TestParsedText(this, "Select node 3", NextNode, "NPC", "Reply when x is something else"); + TestGetParsedNextNode(this, "Get next", NextNode, Importer, false, &NextNode); + // Just test it gets to the fallthrough, we've already tested the continuation from there + TestParsedSelect(this, "Fallthrough select", NextNode, 2); + } + + return true; +} + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestSiblingConditionals, + "SUDSTest.TestSiblingConditionals", + EAutomationTestFlags::EditorContext | + EAutomationTestFlags::ClientContext | + EAutomationTestFlags::ProductFilter) + +bool FTestSiblingConditionals::RunTest(const FString& Parameters) +{ + FSUDSScriptImporter Importer; + FSUDSMessageLogger Logger(false); + TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(SiblingConditionalChoiceInput), SiblingConditionalChoiceInput.Len(), "SiblingConditionalChoiceInput", &Logger, true)); + + // Test the content of the parsing + auto NextNode = Importer.GetNode(0); + if (!TestNotNull("Root node should exist", NextNode)) + return false; + TestParsedText(this, "Text node", NextNode, "NPC", "Hello"); + TestGetParsedNextNode(this, "Get next", NextNode, Importer, false, &NextNode); + if (TestParsedChoice(this, "Root Choice", NextNode, 4)) + { + auto RootChoice = NextNode; + // Sibling choices all get their own nodes + // Bit wasteful to have C -> S -> C -> Text instead of just C -> T but you never know how many choices would + // be in each select level + TestParsedChoiceEdge(this, "Choice 0", RootChoice, 0, "", Importer, &NextNode); + if (TestParsedSelect(this, "Select 0", NextNode, 1)) + { + TestParsedSelectEdge(this, "Select 0", NextNode, 0, "{x} == 0", Importer, &NextNode); + TestParsedChoiceEdge(this, "Choice 0 sub", NextNode, 0, "First choice", Importer, &NextNode); + TestParsedText(this, "Text 0", NextNode, "Player", "I took the 1.1 choice"); + } + TestParsedChoiceEdge(this, "Choice 1", RootChoice, 1, "", Importer, &NextNode); + if (TestParsedSelect(this, "Select 1", NextNode, 1)) + { + TestParsedSelectEdge(this, "Select 1", NextNode, 0, "{y} == 1", Importer, &NextNode); + TestParsedChoiceEdge(this, "Choice 1 sub", NextNode, 0, "Second choice (conditional)", Importer, &NextNode); + TestParsedText(this, "Text 1", NextNode, "Player", "I took the 1.2 choice"); + } + TestParsedChoiceEdge(this, "Choice 2", RootChoice, 2, "", Importer, &NextNode); + if (TestParsedSelect(this, "Select 2", NextNode, 1)) + { + TestParsedSelectEdge(this, "Select 2", NextNode, 0, "{z} == 1", Importer, &NextNode); + TestParsedChoiceEdge(this, "Choice 2 sub", NextNode, 0, "Third choice (conditional)", Importer, &NextNode); + TestParsedText(this, "Text 1", NextNode, "Player", "I took the 1.3 choice"); + } + // Dangling choice with no condition + TestParsedChoiceEdge(this, "Choice 3", RootChoice, 3, "Second Alt Choice", Importer, &NextNode); + } + return true; +} + + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestSiblingConditionalsWithElse, + "SUDSTest.TestSiblingConditionalsWithElse", + EAutomationTestFlags::EditorContext | + EAutomationTestFlags::ClientContext | + EAutomationTestFlags::ProductFilter) + +bool FTestSiblingConditionalsWithElse::RunTest(const FString& Parameters) +{ + FSUDSScriptImporter Importer; + FSUDSMessageLogger Logger(false); + TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(SiblingConditionalChoiceWithElseInput), SiblingConditionalChoiceWithElseInput.Len(), "SiblingConditionalChoiceWithElseInput", &Logger, true)); + + // Test the content of the parsing + auto NextNode = Importer.GetNode(0); + if (!TestNotNull("Root node should exist", NextNode)) + return false; + TestParsedText(this, "Text node", NextNode, "NPC", "Hello"); + TestGetParsedNextNode(this, "Get next", NextNode, Importer, false, &NextNode); + if (TestParsedChoice(this, "Root Choice", NextNode, 3)) + { + auto RootChoice = NextNode; + // Sibling choices all get their own nodes + // Bit wasteful to have C -> S -> C -> Text instead of just C -> T but you never know how many choices would + // be in each select level + TestParsedChoiceEdge(this, "Choice 0", RootChoice, 0, "", Importer, &NextNode); + if (TestParsedSelect(this, "Select 0", NextNode, 3)) + { + const FSUDSParsedNode* SelectNode = NextNode; + TestParsedSelectEdge(this, "Select 0", SelectNode, 0, "{x} == 0", Importer, &NextNode); + TestParsedChoiceEdge(this, "Choice 0 sub", NextNode, 0, "First choice", Importer, &NextNode); + TestParsedText(this, "Text 0", NextNode, "Player", "I took the 1.1 choice"); + + // It's key to have the same "elseif {SomeBool}" for every sibling "if" to prove that the elseifs don't get merged + // just because their own conditions are the same. They're different because they're dependent on their own ifs not + // being true. + TestParsedSelectEdge(this, "Select 0 ELSEIF", SelectNode, 1, "{SomeBool}", Importer, &NextNode); + TestParsedChoiceEdge(this, "Choice 0 sub ELSEIF", NextNode, 0, "First alt choice elseif", Importer, &NextNode); + TestParsedText(this, "Text 0 ELSEIF", NextNode, "Player", "I took the 1.1 elseif choice"); + + TestParsedSelectEdge(this, "Select 0 ELSE", SelectNode, 2, "", Importer, &NextNode); + TestParsedChoiceEdge(this, "Choice 0 sub ELSE", NextNode, 0, "First alt choice", Importer, &NextNode); + TestParsedText(this, "Text 0 ELSE", NextNode, "Player", "I took the 1.1b choice"); + } + TestParsedChoiceEdge(this, "Choice 1", RootChoice, 1, "", Importer, &NextNode); + if (TestParsedSelect(this, "Select 1", NextNode, 3)) + { + const FSUDSParsedNode* SelectNode = NextNode; + TestParsedSelectEdge(this, "Select 1", SelectNode, 0, "{y} == 1", Importer, &NextNode); + TestParsedChoiceEdge(this, "Choice 1 sub", NextNode, 0, "Second choice (conditional)", Importer, &NextNode); + TestParsedText(this, "Text 1", NextNode, "Player", "I took the 1.2 choice"); + + TestParsedSelectEdge(this, "Select 1 ELSEIF", SelectNode, 1, "{SomeBool}", Importer, &NextNode); + TestParsedChoiceEdge(this, "Choice 1 sub ELSEIF", NextNode, 0, "Second alt choice elseif", Importer, &NextNode); + TestParsedText(this, "Text 1 ELSEIF", NextNode, "Player", "I took the 1.2 elseif choice"); + + TestParsedSelectEdge(this, "Select 1 ELSE", SelectNode, 2, "", Importer, &NextNode); + TestParsedChoiceEdge(this, "Choice 1 sub ELSE", NextNode, 0, "Second alt choice", Importer, &NextNode); + TestParsedText(this, "Text 1 ELSE", NextNode, "Player", "I took the 1.2b choice"); + } + TestParsedChoiceEdge(this, "Choice 2", RootChoice, 2, "", Importer, &NextNode); + if (TestParsedSelect(this, "Select 2", NextNode, 3)) + { + const FSUDSParsedNode* SelectNode = NextNode; + TestParsedSelectEdge(this, "Select 2", SelectNode, 0, "{z} == 1", Importer, &NextNode); + TestParsedChoiceEdge(this, "Choice 2 sub", NextNode, 0, "Third choice (conditional)", Importer, &NextNode); + TestParsedText(this, "Text 2", NextNode, "Player", "I took the 1.3 choice"); + + TestParsedSelectEdge(this, "Select 2 ELSEIF", SelectNode, 1, "{SomeBool}", Importer, &NextNode); + TestParsedChoiceEdge(this, "Choice 2 sub ELSEIF", NextNode, 0, "Third alt choice elseif", Importer, &NextNode); + TestParsedText(this, "Text 2 ELSE", NextNode, "Player", "I took the 1.3 elseif choice"); + + TestParsedSelectEdge(this, "Select 2 ELSE", SelectNode, 2, "", Importer, &NextNode); + TestParsedChoiceEdge(this, "Choice 2 sub ELSE", NextNode, 0, "Third alt choice", Importer, &NextNode); + TestParsedText(this, "Text 2 ELSE", NextNode, "Player", "I took the 1.3b choice"); + } + // Dangling choice with no condition + TestParsedChoiceEdge(this, "Choice 3", RootChoice, 3, "Second Alt Choice", Importer, &NextNode); + } + + + // Test running + auto Script = NewObject(GetTransientPackage(), "Test"); + const ScopedStringTableHolder StringTableHolder; + Importer.PopulateAsset(Script, StringTableHolder.StringTable); + + // Script shouldn't be the owner of the dialogue but it's the only UObject we've got right now so why not + auto Dlg = USUDSLibrary::CreateDialogue(Script, Script); + + // For the first run, do not set any state + Dlg->Start(); + + TestDialogueText(this, "First node", Dlg, "NPC", "Hello"); + if (TestEqual("Choice count", Dlg->GetNumberOfChoices(), 3)) + { + TestEqual("Choice 0", Dlg->GetChoiceText(0).ToString(), TEXT("First choice")); + TestEqual("Choice 1", Dlg->GetChoiceText(1).ToString(), TEXT("Second alt choice")); + TestEqual("Choice 2", Dlg->GetChoiceText(2).ToString(), TEXT("Third alt choice")); + } + + // restart, set some variables to alter the path + Dlg->SetVariableInt("x", 1); + Dlg->SetVariableInt("y", 1); + Dlg->SetVariableInt("z", 0); + Dlg->Restart(false); + + TestDialogueText(this, "First node", Dlg, "NPC", "Hello"); + if (TestEqual("Choice count", Dlg->GetNumberOfChoices(), 3)) + { + TestEqual("Choice 0", Dlg->GetChoiceText(0).ToString(), TEXT("First alt choice")); + TestEqual("Choice 1", Dlg->GetChoiceText(1).ToString(), TEXT("Second choice (conditional)")); + TestEqual("Choice 2", Dlg->GetChoiceText(2).ToString(), TEXT("Third alt choice")); + } + + // restart, set some variables to alter the path + Dlg->SetVariableBoolean("SomeBool", true); + Dlg->SetVariableInt("x", 1); + Dlg->SetVariableInt("y", 0); + Dlg->SetVariableInt("z", 1); + Dlg->Restart(false); + + TestDialogueText(this, "First node", Dlg, "NPC", "Hello"); + if (TestEqual("Choice count", Dlg->GetNumberOfChoices(), 3)) + { + TestEqual("Choice 0", Dlg->GetChoiceText(0).ToString(), TEXT("First alt choice elseif")); + TestEqual("Choice 1", Dlg->GetChoiceText(1).ToString(), TEXT("Second alt choice elseif")); + TestEqual("Choice 2", Dlg->GetChoiceText(2).ToString(), TEXT("Third choice (conditional)")); + } + + Script->MarkAsGarbage(); + + + return true; +} + + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestRunningBasicConditionals, + "SUDSTest.TestRunningBasicConditionals", + EAutomationTestFlags::EditorContext | + EAutomationTestFlags::ClientContext | + EAutomationTestFlags::ProductFilter) + + +bool FTestRunningBasicConditionals::RunTest(const FString& Parameters) +{ + FSUDSScriptImporter Importer; + FSUDSMessageLogger Logger(false); + TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(BasicConditionalInput), BasicConditionalInput.Len(), "BasicConditionalInput", &Logger, true)); + + auto Script = NewObject(GetTransientPackage(), "Test"); + const ScopedStringTableHolder StringTableHolder; + Importer.PopulateAsset(Script, StringTableHolder.StringTable); + + // Script shouldn't be the owner of the dialogue but it's the only UObject we've got right now so why not + auto Dlg = USUDSLibrary::CreateDialogue(Script, Script); + + // For the first run, do not set any state + Dlg->Start(); + + TestDialogueText(this, "First node", Dlg, "Player", "Hello"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Text", Dlg, "NPC", "Reply when x is something else"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Text", Dlg, "Player", "the end is false"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Text", Dlg, "NPC", "OK"); + TestFalse("Continue", Dlg->Continue()); + TestTrue("End", Dlg->IsEnded()); + + + // restart, set some variables to alter the path + Dlg->Restart(true); + Dlg->SetVariableInt("x", 1); + Dlg->SetVariableInt("y", -1); + Dlg->SetVariableBoolean("z", true); + + TestDialogueText(this, "First node", Dlg, "Player", "Hello"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Text", Dlg, "NPC", "Reply when x == 1"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Text", Dlg, "Player", "the end is true"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Text", Dlg, "NPC", "OK"); + TestFalse("Continue", Dlg->Continue()); + TestTrue("End", Dlg->IsEnded()); + + + // restart, set some variables to alter the path + Dlg->Restart(true); + Dlg->SetVariableInt("x", 1); + Dlg->SetVariableInt("y", 10); + Dlg->SetVariableBoolean("z", false); + + TestDialogueText(this, "First node", Dlg, "Player", "Hello"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Text", Dlg, "NPC", "Reply when x == 1"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Text", Dlg, "Player", "Player text when x ==1 and y > 0"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Text", Dlg, "Player", "the end is false"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Text", Dlg, "NPC", "OK"); + TestFalse("Continue", Dlg->Continue()); + TestTrue("End", Dlg->IsEnded()); + + Script->MarkAsGarbage(); + return true; + +} + + + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestConditionalChoices, + "SUDSTest.TestConditionalChoices", + EAutomationTestFlags::EditorContext | + EAutomationTestFlags::ClientContext | + EAutomationTestFlags::ProductFilter) + + +bool FTestConditionalChoices::RunTest(const FString& Parameters) +{ + FSUDSMessageLogger Logger(false); + FSUDSScriptImporter Importer; + TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(ConditionalChoiceInput), ConditionalChoiceInput.Len(), "ConditionalChoiceInput", &Logger, true)); + + // Test the content of the parsing + auto NextNode = Importer.GetNode(0); + if (!TestNotNull("Root node should exist", NextNode)) + return false; + + TestParsedText(this, "First node", NextNode, "NPC", "Hello"); + TestGetParsedNextNode(this, "Get next", NextNode, Importer, false, &NextNode); + // This will have one choice edge for the unconditional choice, then an edge to the select node + if (TestParsedChoice(this, "First choice node", NextNode, 3)) + { + auto ChoiceNode = NextNode; + TestParsedChoiceEdge(this, "First choice edge 1", ChoiceNode, 0, "First choice", Importer, &NextNode); + TestParsedText(this, "First choice edge 1 text", NextNode, "Player", "I took the 1.1 choice"); + TestGetParsedNextNode(this, "Get next", NextNode, Importer, false, &NextNode); + TestParsedText(this, "Fallthrough to next question", NextNode, "NPC", "OK next question"); + + // Second edge has no text because it's going to a select for other choices + TestParsedChoiceEdge(this, "First choice edge 2", ChoiceNode, 1, "", Importer, &NextNode); + // 2 edges because only if/else + if (TestParsedSelect(this, "First choice edge 2 should link to select", NextNode, 2)) + { + auto SelectNode = NextNode; + TestParsedSelectEdge(this, "Select edge 0", SelectNode, 0, "{y} == 2", Importer, &NextNode); + // 2 choices in this if clause + if (TestParsedChoice(this, "Choice node under first if", NextNode, 2)) + { + auto Choice2 = NextNode; + TestParsedChoiceEdge(this, "y == 2 choice edge 1", Choice2, 0, "Second choice (conditional)", Importer, &NextNode); + TestParsedText(this, "y == 2 choice edge 1 text", NextNode, "Player", "I took the 1.2 choice"); + // This should fall through to text node after the choices + TestGetParsedNextNode(this, "Get next", NextNode, Importer, false, &NextNode); + TestParsedText(this, "Fallthrough to next text", NextNode, "NPC", "OK next question"); + + TestParsedChoiceEdge(this, "y == 2 choice edge 2", Choice2, 1, "Third choice (conditional)", Importer, &NextNode); + TestParsedText(this, "y == 2 choice edge 2 text", NextNode, "Player", "I took the 1.3 choice"); + // This should fall through to text node after the choices + TestGetParsedNextNode(this, "Get next", NextNode, Importer, false, &NextNode); + TestParsedText(this, "Fallthrough to next text", NextNode, "NPC", "OK next question"); + + } + TestParsedSelectEdge(this, "Select edge 1 (else)", SelectNode, 1, "", Importer, &NextNode); + // 1 choice under this else section + if (TestParsedChoice(this, "Choice node under first else", NextNode, 1)) + { + auto Choice3 = NextNode; + TestParsedChoiceEdge(this, "y == 2 choice edge 1", Choice3, 0, "Second Alt Choice", Importer, &NextNode); + TestParsedText(this, "Else edge text", NextNode, "Player", "I took the alt 1.2 choice"); + TestGetParsedNextNode(this, "Get next", NextNode, Importer, false, &NextNode); + TestParsedText(this, "Fallthrough to next question", NextNode, "NPC", "OK next question"); + } + + // Final fallthrough choice, unconditional + TestParsedChoiceEdge(this, "First choice edge 3", ChoiceNode, 2, "Common last choice", Importer, &NextNode); + TestParsedText(this, "Fallthrough to next text", NextNode, "Player", "I took the 1.4 choice"); + TestGetParsedNextNode(this, "Get next", NextNode, Importer, false, &NextNode); + TestParsedText(this, "Fallthrough to next question", NextNode, "NPC", "OK next question"); + + TestGetParsedNextNode(this, "Get next", NextNode, Importer, false, &NextNode); + // This time the first choice is already in the select, BUT we should have inserted an extra choice anyway + // Should be 2 edges because the last common choice should have attached itself + if (TestParsedChoice(this, "Inserted choice before select", NextNode, 2)) + { + auto InsertedChoice = NextNode; + TestParsedChoiceEdge(this, "Inserted choice edge 1", InsertedChoice, 0, "", Importer, &NextNode); + + // There's an if, elseif, and else edge + if (TestParsedSelect(this, "Select node under next question", NextNode, 3)) + { + auto SelectNode2 = NextNode; + TestParsedSelectEdge(this, "Select edge 0", SelectNode2, 0, "{y} == 0", Importer, &NextNode); + // Choice node under if, only 1 in this section + if (TestParsedChoice(this, "Choice node under select", NextNode, 1)) + { + TestParsedChoiceEdge(this, "First choice edge", NextNode, 0, "First conditional choice", Importer, &NextNode); + TestParsedText(this, "First choice text", NextNode, "Player", "I took the 2.1 choice"); + TestGetParsedNextNode(this, "Get next", NextNode, Importer, false, &NextNode); + TestParsedText(this, "Final fallthrough", NextNode, "NPC", "Bye"); + + } + TestParsedSelectEdge(this, "Select edge 1", SelectNode2, 1, "{y} == 1", Importer, &NextNode); + // Choice node under elseif, only 1 in this section + if (TestParsedChoice(this, "Choice node under select 2", NextNode, 2)) + { + auto Choice4 = NextNode; + TestParsedChoiceEdge(this, "Second choice edge 1", Choice4, 0, "Second conditional choice", Importer, &NextNode); + TestParsedText(this, "First choice text", NextNode, "Player", "I took the 2.2 choice"); + TestGetParsedNextNode(this, "Get next", NextNode, Importer, false, &NextNode); + TestParsedText(this, "Final fallthrough", NextNode, "NPC", "Bye"); + + // Next edge goes to a select + TestParsedChoiceEdge(this, "Second choice edge 2", Choice4, 1, "", Importer, &NextNode); + // There is only an "if" in this nested select. No implicit "else" edge should exist because it's a choice node + if (TestParsedSelect(this, "Select node under next question", NextNode, 1)) + { + TestParsedSelectEdge(this, "Nested select edge 1", NextNode, 0, "{q} == 10", Importer, &NextNode); + if (TestParsedChoice(this, "Choice node under nested select", NextNode, 1)) + { + TestParsedChoiceEdge(this, "Nested choice edge 1", NextNode, 0, "Nested conditional choice", Importer, &NextNode); + TestParsedText(this, "First choice text", NextNode, "Player", "I took the 2.2.1 choice"); + TestGetParsedNextNode(this, "Get next", NextNode, Importer, false, &NextNode); + TestParsedText(this, "Final fallthrough", NextNode, "NPC", "Bye"); + } + } + + } + TestParsedSelectEdge(this, "Select edge 2", SelectNode2, 2, "", Importer, &NextNode); + // Choice node under elseif, only 1 in this section + if (TestParsedChoice(this, "Choice node under select 3", NextNode, 1)) + { + TestParsedChoiceEdge(this, "Choice under select 3 edge", NextNode, 0, "Third conditional choice", Importer, &NextNode); + TestParsedText(this, "First choice text", NextNode, "Player", "I took the 2.3 choice"); + TestGetParsedNextNode(this, "Get next", NextNode, Importer, false, &NextNode); + TestParsedText(this, "Final fallthrough", NextNode, "NPC", "Bye"); + } + + + } + + // Finally the last catch-all choice which attached itself to the choice node we created above the select + TestParsedChoiceEdge(this, "Inserted choice edge 2", InsertedChoice, 1, "Final common choice", Importer, &NextNode); + TestParsedText(this, "First choice text", NextNode, "Player", "I took the 2.4 choice"); + TestGetParsedNextNode(this, "Get next", NextNode, Importer, false, &NextNode); + TestParsedText(this, "Final fallthrough", NextNode, "NPC", "Bye"); + + } + + } + + } + + + return true; +} + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestMixedChoiceAndBranch, + "SUDSTest.TestMixedChoiceAndBranch", + EAutomationTestFlags::EditorContext | + EAutomationTestFlags::ClientContext | + EAutomationTestFlags::ProductFilter) + + +bool FTestMixedChoiceAndBranch::RunTest(const FString& Parameters) +{ + FSUDSMessageLogger Logger(false); + FSUDSScriptImporter Importer; + TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(MixedChoiceAndBranchInput), MixedChoiceAndBranchInput.Len(), "MixedChoiceAndBranchInput", &Logger, true)); + + auto Script = NewObject(GetTransientPackage(), "Test"); + const ScopedStringTableHolder StringTableHolder; + Importer.PopulateAsset(Script, StringTableHolder.StringTable); + + // Script shouldn't be the owner of the dialogue but it's the only UObject we've got right now so why not + auto Dlg = USUDSLibrary::CreateDialogue(Script, Script); + + // For the first run, do not set any state + Dlg->Start(); + + TestDialogueText(this, "Text node", Dlg, "NPC", "Hello"); + TestEqual("Num choices", Dlg->GetNumberOfChoices(), 3); + TestEqual("Num choices", Dlg->GetChoiceText(0).ToString(), "First choice"); + TestEqual("Num choices", Dlg->GetChoiceText(1).ToString(), "Second Alt Choice"); + TestEqual("Num choices", Dlg->GetChoiceText(2).ToString(), "Common last choice"); + TestTrue("Choose", Dlg->Choose(0)); + TestDialogueText(this, "Text node", Dlg, "Player", "I took the 1.1 choice"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Text node", Dlg, "NPC", "Bye"); + + // Choices are on first node so need to set before restart + Dlg->SetVariableInt("x", 10); + Dlg->Restart(false); + TestDialogueText(this, "Text node", Dlg, "NPC", "Hello"); + if (TestEqual("Num choices", Dlg->GetNumberOfChoices(), 4)) + { + TestEqual("ChoiceText", Dlg->GetChoiceText(0).ToString(), "First choice"); + TestEqual("ChoiceText", Dlg->GetChoiceText(1).ToString(), "Second choice (conditional)"); + TestEqual("ChoiceText", Dlg->GetChoiceText(2).ToString(), "Third choice (conditional)"); + TestEqual("ChoiceText", Dlg->GetChoiceText(3).ToString(), "Common last choice"); + TestTrue("Choose", Dlg->Choose(2)); + TestDialogueText(this, "Text node", Dlg, "Player", "I took the 1.3 choice"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Text node", Dlg, "NPC", "Bye"); + } + + Dlg->SetVariableBoolean("alreadyvisited", true); + Dlg->SetVariableInt("y", -1); + Dlg->Restart(false); + TestDialogueText(this, "Text node", Dlg, "NPC", "Hello again"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Text node", Dlg, "Player", "Y is less than 0"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Text node", Dlg, "NPC", "How interesting"); + if (TestEqual("Num choices", Dlg->GetNumberOfChoices(), 2)) + { + TestEqual("Choice text", Dlg->GetChoiceText(0).ToString(), "You don't sound that interested"); + TestEqual("Choice text", Dlg->GetChoiceText(1).ToString(), "Well, better be off"); + TestTrue("Choose", Dlg->Choose(1)); + TestDialogueText(this, "Text node", Dlg, "NPC", "Bye"); + } + + Dlg->SetVariableBoolean("alreadyvisited", true); + Dlg->SetVariableInt("y", -1); + Dlg->SetVariableInt("x", 0); + Dlg->Restart(false); + TestDialogueText(this, "Text node", Dlg, "NPC", "Hello again"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Text node", Dlg, "Player", "Y is less than 0"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Text node", Dlg, "NPC", "How interesting"); + if (TestEqual("Num choices", Dlg->GetNumberOfChoices(), 3)) + { + TestEqual("Choice text", Dlg->GetChoiceText(0).ToString(), "You don't sound that interested"); + TestEqual("Choice text", Dlg->GetChoiceText(1).ToString(), "Also x is zero"); + TestEqual("Choice text", Dlg->GetChoiceText(2).ToString(), "Well, better be off"); + TestTrue("Choose", Dlg->Choose(1)); + TestDialogueText(this, "Text node", Dlg, "NPC", "Fascinating"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Text node", Dlg, "Player", "This is some fallthrough text"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Text node", Dlg, "NPC", "Bye"); + } + + + Dlg->SetVariableBoolean("alreadyvisited", true); + Dlg->SetVariableInt("y", 123); + Dlg->SetVariableBoolean("ponderous", true); + Dlg->Restart(false); + TestDialogueText(this, "Text node", Dlg, "NPC", "Hello again"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Text node", Dlg, "Player", "Who knows what Y is anyway"); + if (TestEqual("Num choices", Dlg->GetNumberOfChoices(), 3)) + { + TestEqual("Choice text", Dlg->GetChoiceText(0).ToString(), "Who knows what anything is?"); + TestEqual("Choice text", Dlg->GetChoiceText(1).ToString(), "It's more than I can count on one hand"); + TestEqual("Choice text", Dlg->GetChoiceText(2).ToString(), "I'm done with this"); + TestTrue("Choose", Dlg->Choose(0)); + TestDialogueText(this, "Text node", Dlg, "NPC", "Get out"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Text node", Dlg, "Player", "This is some fallthrough text"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Text node", Dlg, "NPC", "Bye"); + + } + + Dlg->SetVariableBoolean("alreadyvisited", true); + Dlg->SetVariableInt("y", 4); + Dlg->SetVariableBoolean("ponderous", true); + Dlg->Restart(false); + TestDialogueText(this, "Text node", Dlg, "NPC", "Hello again"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Text node", Dlg, "Player", "Who knows what Y is anyway"); + if (TestEqual("Num choices", Dlg->GetNumberOfChoices(), 3)) + { + TestEqual("Choice text", Dlg->GetChoiceText(0).ToString(), "Who knows what anything is?"); + TestEqual("Choice text", Dlg->GetChoiceText(1).ToString(), "It's kind of small though"); + TestEqual("Choice text", Dlg->GetChoiceText(2).ToString(), "I'm done with this"); + TestTrue("Choose", Dlg->Choose(2)); + TestDialogueText(this, "Text node", Dlg, "Player", "This is some fallthrough text"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Text node", Dlg, "NPC", "Bye"); + + } + + Script->MarkAsGarbage(); + return true; +} + +const FString ChoiceAfterConditionals = R"RAWSUD( +NPC: Hello +[if {AltPath}] +Player: This is the alternate path before choice +[else] +Player: This is the main path +[endif] + * Choice 1 + NPC: Choice 1 after conditional + * Choice 2 + NPC: Choice 2 after conditional + +NPC: Bye +)RAWSUD"; + + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestChoiceAfterConditionals, + "SUDSTest.TestChoiceAfterConditionals", + EAutomationTestFlags::EditorContext | + EAutomationTestFlags::ClientContext | + EAutomationTestFlags::ProductFilter) + + +bool FTestChoiceAfterConditionals::RunTest(const FString& Parameters) +{ + FSUDSMessageLogger Logger(false); + FSUDSScriptImporter Importer; + TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(ChoiceAfterConditionals), ChoiceAfterConditionals.Len(), "ChoiceAfterConditionals", &Logger, true)); + + auto Script = NewObject(GetTransientPackage(), "Test"); + const ScopedStringTableHolder StringTableHolder; + Importer.PopulateAsset(Script, StringTableHolder.StringTable); + + // Script shouldn't be the owner of the dialogue but it's the only UObject we've got right now so why not + auto Dlg = USUDSLibrary::CreateDialogue(Script, Script); + + // For the first run, do not set any state + Dlg->Start(); + + TestDialogueText(this, "Text node", Dlg, "NPC", "Hello"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Text node", Dlg, "Player", "This is the main path"); + TestEqual("Number of choices", Dlg->GetNumberOfChoices(), 2); + TestEqual("Choice text", Dlg->GetChoiceText(0).ToString(), "Choice 1"); + TestEqual("Choice text", Dlg->GetChoiceText(1).ToString(), "Choice 2"); + TestTrue("Choose", Dlg->Choose(0)); + TestDialogueText(this, "Text node", Dlg, "NPC", "Choice 1 after conditional"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Text node", Dlg, "NPC", "Bye"); + + Script->MarkAsGarbage(); + return true; +} + +const FString ChoiceAfterNestedConditionals = R"RAWSUD( +NPC: Hello +[if {AltPath}] + Player: This is the alternate path before choice +[else] + Player: This is the main path + [if {AltPath2}] + Player: An alt-alt path + [endif] +[endif] +:choices + * Choice 1 + NPC: Choice 1 after conditional + * Choice 2 + NPC: Choice 2 after conditional + +NPC: Bye +)RAWSUD"; + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestChoiceAfterNestedConditionals, + "SUDSTest.TestChoiceAfterNestedConditionals", + EAutomationTestFlags::EditorContext | + EAutomationTestFlags::ClientContext | + EAutomationTestFlags::ProductFilter) + + +bool FTestChoiceAfterNestedConditionals::RunTest(const FString& Parameters) +{ + FSUDSMessageLogger Logger(false); + FSUDSScriptImporter Importer; + TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(ChoiceAfterNestedConditionals), ChoiceAfterNestedConditionals.Len(), "ChoiceAfterNestedConditionals", &Logger, true)); + + auto Script = NewObject(GetTransientPackage(), "Test"); + const ScopedStringTableHolder StringTableHolder; + Importer.PopulateAsset(Script, StringTableHolder.StringTable); + + // Script shouldn't be the owner of the dialogue but it's the only UObject we've got right now so why not + auto Dlg = USUDSLibrary::CreateDialogue(Script, Script); + + // For the first run, do not set any state + Dlg->Start(); + + TestDialogueText(this, "Text node", Dlg, "NPC", "Hello"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Text node", Dlg, "Player", "This is the main path"); + TestEqual("Number of choices", Dlg->GetNumberOfChoices(), 2); + TestEqual("Choice text", Dlg->GetChoiceText(0).ToString(), "Choice 1"); + TestEqual("Choice text", Dlg->GetChoiceText(1).ToString(), "Choice 2"); + TestTrue("Choose", Dlg->Choose(0)); + TestDialogueText(this, "Text node", Dlg, "NPC", "Choice 1 after conditional"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Text node", Dlg, "NPC", "Bye"); + + Script->MarkAsGarbage(); + return true; +} + +const FString VarsSetBetweenTextAndChoiceChoice = R"RAWSUD( +# prove that the set node is run before the choices are evaluated even though it's after the text node +NPC: Hello +[set AltPath true] +[if {AltPath}] + * Alt Choice + Player: This is the alternate path choice +[else] + * Main Choice + Player: Main path choice +[endif] + * Common Choice + Player: Common choice here +NPC: Bye +)RAWSUD"; + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FVarsSetBetweenTextAndChoiceChoice, + "SUDSTest.VarsSetBetweenTextAndChoiceChoice", + EAutomationTestFlags::EditorContext | + EAutomationTestFlags::ClientContext | + EAutomationTestFlags::ProductFilter) + + +bool FVarsSetBetweenTextAndChoiceChoice::RunTest(const FString& Parameters) +{ + FSUDSMessageLogger Logger(false); + FSUDSScriptImporter Importer; + TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(VarsSetBetweenTextAndChoiceChoice), VarsSetBetweenTextAndChoiceChoice.Len(), "VarsSetBetweenTextAndChoiceChoice", &Logger, true)); + + auto Script = NewObject(GetTransientPackage(), "Test"); + const ScopedStringTableHolder StringTableHolder; + Importer.PopulateAsset(Script, StringTableHolder.StringTable); + + // Script shouldn't be the owner of the dialogue but it's the only UObject we've got right now so why not + auto Dlg = USUDSLibrary::CreateDialogue(Script, Script); + + // For the first run, do not set any state + Dlg->Start(); + + TestDialogueText(this, "Text node", Dlg, "NPC", "Hello"); + TestEqual("Number of choices", Dlg->GetNumberOfChoices(), 2); + TestEqual("Choice text", Dlg->GetChoiceText(0).ToString(), "Alt Choice"); + TestEqual("Choice text", Dlg->GetChoiceText(1).ToString(), "Common Choice"); + + Script->MarkAsGarbage(); + return true; +} + + +const FString MultipleOptionalChoicesWithLinesBetweenTextAndChoice = R"RAWSUD( +NPC: Hello +NPC: Testing +# This event being here could mess up multiple sibling optional choices +[event SomeEvent] + * Choice 1? + Player: Choice 1 + [if {Opt2}] + * Optional Choice 2? + Player: Choice 2 + [endif] + [if {Opt3}] + * Optional Choice 3? + Player: Choice 3 + [endif] + * Back + Player: going back +NPC: Fallthrough +)RAWSUD"; + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestMultipleOptionalChoicesWithLinesBetweenTextAndChoice, + "SUDSTest.TestMultipleOptionalChoicesWithLinesBetweenTextAndChoice", + EAutomationTestFlags::EditorContext | + EAutomationTestFlags::ClientContext | + EAutomationTestFlags::ProductFilter) + + +bool FTestMultipleOptionalChoicesWithLinesBetweenTextAndChoice::RunTest(const FString& Parameters) +{ + FSUDSMessageLogger Logger(false); + FSUDSScriptImporter Importer; + TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(MultipleOptionalChoicesWithLinesBetweenTextAndChoice), MultipleOptionalChoicesWithLinesBetweenTextAndChoice.Len(), "MultipleOptionalChoicesWithLinesBetweenTextAndChoice", &Logger, true)); + + auto Script = NewObject(GetTransientPackage(), "Test"); + const ScopedStringTableHolder StringTableHolder; + Importer.PopulateAsset(Script, StringTableHolder.StringTable); + + // Script shouldn't be the owner of the dialogue but it's the only UObject we've got right now so why not + auto Dlg = USUDSLibrary::CreateDialogue(Script, Script); + + // For the first run, do not set any state + Dlg->Start(); + + TestDialogueText(this, "Text node", Dlg, "NPC", "Hello"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Text node", Dlg, "NPC", "Testing"); + TestEqual("Number of choices", Dlg->GetNumberOfChoices(), 2); + TestEqual("Choice text 0", Dlg->GetChoiceText(0).ToString(), "Choice 1?"); + TestEqual("Choice text 1", Dlg->GetChoiceText(1).ToString(), "Back"); + + Dlg->Restart(true); + Dlg->SetVariableBoolean("Opt2", true); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Text node", Dlg, "NPC", "Testing"); + TestEqual("Number of choices", Dlg->GetNumberOfChoices(), 3); + TestEqual("Choice text 0", Dlg->GetChoiceText(0).ToString(), "Choice 1?"); + TestEqual("Choice text 1", Dlg->GetChoiceText(1).ToString(), "Optional Choice 2?"); + TestEqual("Choice text 2", Dlg->GetChoiceText(2).ToString(), "Back"); + + Dlg->Restart(true); + Dlg->SetVariableBoolean("Opt3", true); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Text node", Dlg, "NPC", "Testing"); + TestEqual("Number of choices", Dlg->GetNumberOfChoices(), 3); + TestEqual("Choice text 0", Dlg->GetChoiceText(0).ToString(), "Choice 1?"); + TestEqual("Choice text 1", Dlg->GetChoiceText(1).ToString(), "Optional Choice 3?"); + TestEqual("Choice text 2", Dlg->GetChoiceText(2).ToString(), "Back"); + + Dlg->Restart(true); + Dlg->SetVariableBoolean("Opt2", true); + Dlg->SetVariableBoolean("Opt3", true); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Text node", Dlg, "NPC", "Testing"); + TestEqual("Number of choices", Dlg->GetNumberOfChoices(), 4); + TestEqual("Choice text 0", Dlg->GetChoiceText(0).ToString(), "Choice 1?"); + TestEqual("Choice text 1", Dlg->GetChoiceText(1).ToString(), "Optional Choice 2?"); + TestEqual("Choice text 2", Dlg->GetChoiceText(2).ToString(), "Optional Choice 3?"); + TestEqual("Choice text 3", Dlg->GetChoiceText(3).ToString(), "Back"); + + Script->MarkAsGarbage(); + return true; +} + + +UE_ENABLE_OPTIMIZATION \ No newline at end of file diff --git a/Plugins/SUDS/Source/SUDSTest/Private/TestEventSub.cpp b/Plugins/SUDS/Source/SUDSTest/Private/TestEventSub.cpp new file mode 100644 index 00000000..88eef6e5 --- /dev/null +++ b/Plugins/SUDS/Source/SUDSTest/Private/TestEventSub.cpp @@ -0,0 +1,20 @@ +#include "TestEventSub.h" + +#include "SUDSDialogue.h" + +void UTestEventSub::Init(USUDSDialogue* Dlg) +{ + Dlg->OnEvent.AddDynamic(this, &UTestEventSub::OnEvent); + Dlg->OnVariableChanged.AddDynamic(this, &UTestEventSub::OnVariableChanged); + +} + +void UTestEventSub::OnEvent(USUDSDialogue* Dlg, FName EventName, const TArray& Args) +{ + EventRecords.Add(FEventRecord { EventName, Args }); +} + +void UTestEventSub::OnVariableChanged(USUDSDialogue* Dlg, FName VarName, const FSUDSValue& Value, bool bFromScript) +{ + SetVarRecords.Add(FSetVarRecord { VarName, Value, bFromScript }); +} diff --git a/Plugins/SUDS/Source/SUDSTest/Private/TestEventSub.h b/Plugins/SUDS/Source/SUDSTest/Private/TestEventSub.h new file mode 100644 index 00000000..5f8208dc --- /dev/null +++ b/Plugins/SUDS/Source/SUDSTest/Private/TestEventSub.h @@ -0,0 +1,39 @@ +#pragma once + +#include "CoreMinimal.h" +#include "SUDSValue.h" +#include "UObject/Object.h" +#include "TestEventSub.generated.h" + +class USUDSDialogue; +UCLASS() +class SUDSTEST_API UTestEventSub : public UObject +{ + GENERATED_BODY() + +public: + void Init(USUDSDialogue* Dlg); + + struct FEventRecord + { + FName Name; + TArray Args; + }; + struct FSetVarRecord + { + FName Name; + FSUDSValue Value; + bool bFromScript; + }; + + TArray EventRecords; + TArray SetVarRecords; + + UFUNCTION() + void OnEvent(USUDSDialogue* Dlg, FName EventName, const TArray& Args); + + UFUNCTION() + void OnVariableChanged(USUDSDialogue* Dlg, FName VarName, const FSUDSValue& Value, bool bFromScript); + + +}; diff --git a/Plugins/SUDS/Source/SUDSTest/Private/TestEvents.cpp b/Plugins/SUDS/Source/SUDSTest/Private/TestEvents.cpp new file mode 100644 index 00000000..e7673a70 --- /dev/null +++ b/Plugins/SUDS/Source/SUDSTest/Private/TestEvents.cpp @@ -0,0 +1,278 @@ +#include "SUDSDialogue.h" +#include "SUDSLibrary.h" +#include "SUDSMessageLogger.h" +#include "SUDSScript.h" +#include "SUDSScriptImporter.h" +#include "TestEventSub.h" +#include "TestParticipant.h" +#include "TestUtils.h" + +UE_DISABLE_OPTIMIZATION + +const FString EventParsingInput = R"RAWSUD( +Player: Ow do? +[set IntVar 2] +[set FloatVar 66.67] +[set StringVar "Ey up"] +[event SummatHappened "2 penneth", 99.99, masculine, {IntVar}, 42, false] +NPC: Alreet chook +[event Well.Blow.Me.Down {FloatVar}, true, {StringVar}] +[event Calculated {FloatVar} + 10, true or false, {StringVar}] +[event Actor.Emote `Player`, `Question`] +Player: Tara +)RAWSUD"; + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestEvents, + "SUDSTest.TestEvents", + EAutomationTestFlags::EditorContext | + EAutomationTestFlags::ClientContext | + EAutomationTestFlags::ProductFilter) + + +bool FTestEvents::RunTest(const FString& Parameters) +{ + FSUDSMessageLogger Logger(false); + FSUDSScriptImporter Importer; + TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(EventParsingInput), EventParsingInput.Len(), "EventParsingInput", &Logger, true)); + + auto Script = NewObject(GetTransientPackage(), "Test"); + const ScopedStringTableHolder StringTableHolder; + Importer.PopulateAsset(Script, StringTableHolder.StringTable); + + // Script shouldn't be the owner of the dialogue but it's the only UObject we've got right now so why not + auto Dlg = USUDSLibrary::CreateDialogue(Script, Script); + + // Subscriber + auto EvtSub = NewObject(); + EvtSub->Init(Dlg); + + // Participant + auto Participant = NewObject(); + Participant->TestNumber = 0; + Dlg->AddParticipant(Participant); + + Dlg->Start(); + + TestDialogueText(this, "Line 1", Dlg, "Player", "Ow do?"); + TestTrue("Continue", Dlg->Continue()); + + // Should have had an event + if (TestEqual("Event sub should have received", EvtSub->EventRecords.Num(), 1)) + { + TestEqual("Event name", EvtSub->EventRecords[0].Name.ToString(), "SummatHappened"); + if (TestEqual("Event sub arg count", EvtSub->EventRecords[0].Args.Num(), 6)) + { + TestEqual("Event sub arg 0 type", EvtSub->EventRecords[0].Args[0].GetType(), ESUDSValueType::Text); + TestEqual("Event sub arg 0 value", EvtSub->EventRecords[0].Args[0].GetTextValue().ToString(), "2 penneth"); + TestEqual("Event sub arg 1 type", EvtSub->EventRecords[0].Args[1].GetType(), ESUDSValueType::Float); + TestEqual("Event sub arg 1 value", EvtSub->EventRecords[0].Args[1].GetFloatValue(), 99.99f); + TestEqual("Event sub arg 2 type", EvtSub->EventRecords[0].Args[2].GetType(), ESUDSValueType::Gender); + TestEqual("Event sub arg 2 value", EvtSub->EventRecords[0].Args[2].GetGenderValue(), ETextGender::Masculine); + TestEqual("Event sub arg 3 type", EvtSub->EventRecords[0].Args[3].GetType(), ESUDSValueType::Int); + TestEqual("Event sub arg 3 value", EvtSub->EventRecords[0].Args[3].GetIntValue(), 2); + TestEqual("Event sub arg 4 type", EvtSub->EventRecords[0].Args[4].GetType(), ESUDSValueType::Int); + TestEqual("Event sub arg 4 value", EvtSub->EventRecords[0].Args[4].GetIntValue(), 42); + TestEqual("Event sub arg 5 type", EvtSub->EventRecords[0].Args[5].GetType(), ESUDSValueType::Boolean); + TestEqual("Event sub arg 5 value", EvtSub->EventRecords[0].Args[5].GetBooleanValue(), false); + } + } + if (TestEqual("Participant should have received", Participant->EventRecords.Num(), 1)) + { + TestEqual("Event name", Participant->EventRecords[0].Name.ToString(), "SummatHappened"); + if (TestEqual("Participant arg count", Participant->EventRecords[0].Args.Num(), 6)) + { + TestEqual("Participant arg 0 type", Participant->EventRecords[0].Args[0].GetType(), ESUDSValueType::Text); + TestEqual("Participant arg 0 value", Participant->EventRecords[0].Args[0].GetTextValue().ToString(), "2 penneth"); + TestEqual("Participant arg 1 type", Participant->EventRecords[0].Args[1].GetType(), ESUDSValueType::Float); + TestEqual("Participant arg 1 value", Participant->EventRecords[0].Args[1].GetFloatValue(), 99.99f); + TestEqual("Participant arg 2 type", Participant->EventRecords[0].Args[2].GetType(), ESUDSValueType::Gender); + TestEqual("Participant arg 2 value", Participant->EventRecords[0].Args[2].GetGenderValue(), ETextGender::Masculine); + TestEqual("Participant arg 3 type", Participant->EventRecords[0].Args[3].GetType(), ESUDSValueType::Int); + TestEqual("Participant arg 3 value", Participant->EventRecords[0].Args[3].GetIntValue(), 2); + TestEqual("Participant arg 4 type", Participant->EventRecords[0].Args[4].GetType(), ESUDSValueType::Int); + TestEqual("Participant arg 4 value", Participant->EventRecords[0].Args[4].GetIntValue(), 42); + TestEqual("Participant arg 5 type", Participant->EventRecords[0].Args[5].GetType(), ESUDSValueType::Boolean); + TestEqual("Participant arg 5 value", Participant->EventRecords[0].Args[5].GetBooleanValue(), false); + + } + } + if (TestEqual("Event var sub should have received", EvtSub->SetVarRecords.Num(), 10)) + { + // First 7 are from the participant setting variables at startup + TestEqual("Event var sub arg 0 name", EvtSub->SetVarRecords[0].Name.ToString(), "SpeakerName.Player"); + TestEqual("Event var sub arg 0 type", EvtSub->SetVarRecords[0].Value.GetType(), ESUDSValueType::Text); + TestEqual("Event var sub arg 0 value", EvtSub->SetVarRecords[0].Value.GetTextValue().ToString(), "Protagonist"); + TestFalse("Event var sub arg 0 fromscript", EvtSub->SetVarRecords[0].bFromScript); + + TestEqual("Event var sub arg 1 name", EvtSub->SetVarRecords[1].Name.ToString(), "SpeakerName.NPC"); + TestEqual("Event var sub arg 1 type", EvtSub->SetVarRecords[1].Value.GetType(), ESUDSValueType::Text); + TestEqual("Event var sub arg 1 value", EvtSub->SetVarRecords[1].Value.GetTextValue().ToString(), "An NPC"); + TestFalse("Event var sub arg 1 fromscript", EvtSub->SetVarRecords[1].bFromScript); + + TestEqual("Event var sub arg 2 name", EvtSub->SetVarRecords[2].Name.ToString(), "NumCats"); + TestEqual("Event var sub arg 2 type", EvtSub->SetVarRecords[2].Value.GetType(), ESUDSValueType::Int); + TestEqual("Event var sub arg 2 value", EvtSub->SetVarRecords[2].Value.GetIntValue(), 3); + TestFalse("Event var sub arg 2 fromscript", EvtSub->SetVarRecords[2].bFromScript); + + TestEqual("Event var sub arg 3 name", EvtSub->SetVarRecords[3].Name.ToString(), "FriendName"); + TestEqual("Event var sub arg 3 type", EvtSub->SetVarRecords[3].Value.GetType(), ESUDSValueType::Text); + TestEqual("Event var sub arg 3 value", EvtSub->SetVarRecords[3].Value.GetTextValue().ToString(), "Susan"); + TestFalse("Event var sub arg 3 fromscript", EvtSub->SetVarRecords[3].bFromScript); + + TestEqual("Event var sub arg 4 name", EvtSub->SetVarRecords[4].Name.ToString(), "Gender"); + TestEqual("Event var sub arg 4 type", EvtSub->SetVarRecords[4].Value.GetType(), ESUDSValueType::Gender); + TestEqual("Event var sub arg 4 value", EvtSub->SetVarRecords[4].Value.GetGenderValue(), ETextGender::Feminine); + TestFalse("Event var sub arg 4 fromscript", EvtSub->SetVarRecords[4].bFromScript); + + TestEqual("Event var sub arg 5 name", EvtSub->SetVarRecords[5].Name.ToString(), "FloatVal"); + TestEqual("Event var sub arg 5 type", EvtSub->SetVarRecords[5].Value.GetType(), ESUDSValueType::Float); + TestEqual("Event var sub arg 5 value", EvtSub->SetVarRecords[5].Value.GetFloatValue(), 12.567f); + TestFalse("Event var sub arg 5 fromscript", EvtSub->SetVarRecords[5].bFromScript); + + TestEqual("Event var sub arg 6 name", EvtSub->SetVarRecords[6].Name.ToString(), "BoolVal"); + TestEqual("Event var sub arg 6 type", EvtSub->SetVarRecords[6].Value.GetType(), ESUDSValueType::Boolean); + TestEqual("Event var sub arg 6 value", EvtSub->SetVarRecords[6].Value.GetBooleanValue(), true); + TestFalse("Event var sub arg 6 fromscript", EvtSub->SetVarRecords[6].bFromScript); + + // Next 3 are from script + TestEqual("Event var sub arg 7 name", EvtSub->SetVarRecords[7].Name.ToString(), "IntVar"); + TestEqual("Event var sub arg 7 type", EvtSub->SetVarRecords[7].Value.GetType(), ESUDSValueType::Int); + TestEqual("Event var sub arg 7 value", EvtSub->SetVarRecords[7].Value.GetIntValue(), 2); + TestTrue("Event var sub arg 7 fromscript", EvtSub->SetVarRecords[7].bFromScript); + + TestEqual("Event var sub arg 8 name", EvtSub->SetVarRecords[8].Name.ToString(), "FloatVar"); + TestEqual("Event var sub arg 8 type", EvtSub->SetVarRecords[8].Value.GetType(), ESUDSValueType::Float); + TestEqual("Event var sub arg 8 value", EvtSub->SetVarRecords[8].Value.GetFloatValue(), 66.67f); + TestTrue("Event var sub arg 8 fromscript", EvtSub->SetVarRecords[8].bFromScript); + + TestEqual("Event var sub arg 9 name", EvtSub->SetVarRecords[9].Name.ToString(), "StringVar"); + TestEqual("Event var sub arg 9 type", EvtSub->SetVarRecords[9].Value.GetType(), ESUDSValueType::Text); + TestEqual("Event var sub arg 9 value", EvtSub->SetVarRecords[9].Value.GetTextValue().ToString(), "Ey up"); + TestTrue("Event var sub arg 9 fromscript", EvtSub->SetVarRecords[9].bFromScript); + } + if (TestEqual("Participant var should have received", Participant->SetVarRecords.Num(), 10)) + { + // First 7 are from the participant setting variables at startup + TestEqual("Participant var arg 0 name", Participant->SetVarRecords[0].Name.ToString(), "SpeakerName.Player"); + TestEqual("Participant var arg 0 type", Participant->SetVarRecords[0].Value.GetType(), ESUDSValueType::Text); + TestEqual("Participant var arg 0 value", Participant->SetVarRecords[0].Value.GetTextValue().ToString(), "Protagonist"); + TestFalse("Participant var arg 0 fromscript", Participant->SetVarRecords[0].bFromScript); + + TestEqual("Participant var arg 1 name", Participant->SetVarRecords[1].Name.ToString(), "SpeakerName.NPC"); + TestEqual("Participant var arg 1 type", Participant->SetVarRecords[1].Value.GetType(), ESUDSValueType::Text); + TestEqual("Participant var arg 1 value", Participant->SetVarRecords[1].Value.GetTextValue().ToString(), "An NPC"); + TestFalse("Participant var arg 1 fromscript", Participant->SetVarRecords[1].bFromScript); + + TestEqual("Participant var arg 2 name", Participant->SetVarRecords[2].Name.ToString(), "NumCats"); + TestEqual("Participant var arg 2 type", Participant->SetVarRecords[2].Value.GetType(), ESUDSValueType::Int); + TestEqual("Participant var arg 2 value", Participant->SetVarRecords[2].Value.GetIntValue(), 3); + TestFalse("Participant var arg 2 fromscript", Participant->SetVarRecords[2].bFromScript); + + TestEqual("Participant var arg 3 name", Participant->SetVarRecords[3].Name.ToString(), "FriendName"); + TestEqual("Participant var arg 3 type", Participant->SetVarRecords[3].Value.GetType(), ESUDSValueType::Text); + TestEqual("Participant var arg 3 value", Participant->SetVarRecords[3].Value.GetTextValue().ToString(), "Susan"); + TestFalse("Participant var arg 3 fromscript", Participant->SetVarRecords[3].bFromScript); + + TestEqual("Participant var arg 4 name", Participant->SetVarRecords[4].Name.ToString(), "Gender"); + TestEqual("Participant var arg 4 type", Participant->SetVarRecords[4].Value.GetType(), ESUDSValueType::Gender); + TestEqual("Participant var arg 4 value", Participant->SetVarRecords[4].Value.GetGenderValue(), ETextGender::Feminine); + TestFalse("Participant var arg 4 fromscript", Participant->SetVarRecords[4].bFromScript); + + TestEqual("Participant var arg 5 name", Participant->SetVarRecords[5].Name.ToString(), "FloatVal"); + TestEqual("Participant var arg 5 type", Participant->SetVarRecords[5].Value.GetType(), ESUDSValueType::Float); + TestEqual("Participant var arg 5 value", Participant->SetVarRecords[5].Value.GetFloatValue(), 12.567f); + TestFalse("Participant var arg 5 fromscript", Participant->SetVarRecords[5].bFromScript); + + TestEqual("Participant var arg 6 name", Participant->SetVarRecords[6].Name.ToString(), "BoolVal"); + TestEqual("Participant var arg 6 type", Participant->SetVarRecords[6].Value.GetType(), ESUDSValueType::Boolean); + TestEqual("Participant var arg 6 value", Participant->SetVarRecords[6].Value.GetBooleanValue(), true); + TestFalse("Participant var arg 6 fromscript", Participant->SetVarRecords[6].bFromScript); + + // Next 3 are from script + TestEqual("Participant var arg 7 name", Participant->SetVarRecords[7].Name.ToString(), "IntVar"); + TestEqual("Participant var arg 7 type", Participant->SetVarRecords[7].Value.GetType(), ESUDSValueType::Int); + TestEqual("Participant var arg 7 value", Participant->SetVarRecords[7].Value.GetIntValue(), 2); + TestTrue("Participant var arg 7 fromscript", Participant->SetVarRecords[7].bFromScript); + + TestEqual("Participant var arg 8 name", Participant->SetVarRecords[8].Name.ToString(), "FloatVar"); + TestEqual("Participant var arg 8 type", Participant->SetVarRecords[8].Value.GetType(), ESUDSValueType::Float); + TestEqual("Participant var arg 8 value", Participant->SetVarRecords[8].Value.GetFloatValue(), 66.67f); + TestTrue("Participant var arg 8 fromscript", Participant->SetVarRecords[8].bFromScript); + + TestEqual("Participant var arg 9 name", Participant->SetVarRecords[9].Name.ToString(), "StringVar"); + TestEqual("Participant var arg 9 type", Participant->SetVarRecords[9].Value.GetType(), ESUDSValueType::Text); + TestEqual("Participant var arg 9 value", Participant->SetVarRecords[9].Value.GetTextValue().ToString(), "Ey up"); + TestTrue("Participant var arg 9 fromscript", Participant->SetVarRecords[9].bFromScript); + } + + TestDialogueText(this, "Line 2", Dlg, "NPC", "Alreet chook"); + TestTrue("Continue", Dlg->Continue()); + + if (TestEqual("Event sub should have received", EvtSub->EventRecords.Num(), 4)) + { + TestEqual("Event name", EvtSub->EventRecords[1].Name.ToString(), "Well.Blow.Me.Down"); + if (TestEqual("Event sub arg count", EvtSub->EventRecords[1].Args.Num(), 3)) + { + TestEqual("Event sub arg 0 type", EvtSub->EventRecords[1].Args[0].GetType(), ESUDSValueType::Float); + TestEqual("Event sub arg 0 value", EvtSub->EventRecords[1].Args[0].GetFloatValue(), 66.67f); + TestEqual("Event sub arg 1 type", EvtSub->EventRecords[1].Args[1].GetType(), ESUDSValueType::Boolean); + TestEqual("Event sub arg 1 value", EvtSub->EventRecords[1].Args[1].GetBooleanValue(), true); + TestEqual("Event sub arg 2 type", EvtSub->EventRecords[1].Args[2].GetType(), ESUDSValueType::Text); + TestEqual("Event sub arg 2 value", EvtSub->EventRecords[1].Args[2].GetTextValue().ToString(), "Ey up"); + } + TestEqual("Event name", EvtSub->EventRecords[2].Name.ToString(), "Calculated"); + if (TestEqual("Event sub arg count", EvtSub->EventRecords[2].Args.Num(), 3)) + { + TestEqual("Event sub arg 0 type", EvtSub->EventRecords[2].Args[0].GetType(), ESUDSValueType::Float); + TestEqual("Event sub arg 0 value", EvtSub->EventRecords[2].Args[0].GetFloatValue(), 76.67f); + TestEqual("Event sub arg 2 type", EvtSub->EventRecords[2].Args[1].GetType(), ESUDSValueType::Boolean); + TestEqual("Event sub arg 2 value", EvtSub->EventRecords[2].Args[1].GetBooleanValue(), true); + TestEqual("Event sub arg 2 type", EvtSub->EventRecords[2].Args[2].GetType(), ESUDSValueType::Text); + TestEqual("Event sub arg 2 value", EvtSub->EventRecords[2].Args[2].GetTextValue().ToString(), "Ey up"); + } + TestEqual("Event name", EvtSub->EventRecords[3].Name.ToString(), "Actor.Emote"); + if (TestEqual("Event sub arg count", EvtSub->EventRecords[3].Args.Num(), 2)) + { + TestEqual("Event sub arg 0 type", EvtSub->EventRecords[3].Args[0].GetType(), ESUDSValueType::Name); + TestEqual("Event sub arg 0 value", EvtSub->EventRecords[3].Args[0].GetNameValue(), FName("Player")); + TestEqual("Event sub arg 2 type", EvtSub->EventRecords[3].Args[1].GetType(), ESUDSValueType::Name); + TestEqual("Event sub arg 2 value", EvtSub->EventRecords[3].Args[1].GetNameValue(), FName("Question")); + } + } + if (TestEqual("Participant should have received", Participant->EventRecords.Num(), 4)) + { + TestEqual("Event name", Participant->EventRecords[1].Name.ToString(), "Well.Blow.Me.Down"); + if (TestEqual("Participant arg count", Participant->EventRecords[1].Args.Num(), 3)) + { + TestEqual("Participant arg 0 type", Participant->EventRecords[1].Args[0].GetType(), ESUDSValueType::Float); + TestEqual("Participant arg 0 value", Participant->EventRecords[1].Args[0].GetFloatValue(), 66.67f); + TestEqual("Participant arg 1 type", Participant->EventRecords[1].Args[1].GetType(), ESUDSValueType::Boolean); + TestEqual("Participant arg 1 value", Participant->EventRecords[1].Args[1].GetBooleanValue(), true); + TestEqual("Participant arg 2 type", Participant->EventRecords[1].Args[2].GetType(), ESUDSValueType::Text); + TestEqual("Participant arg 2 value", Participant->EventRecords[1].Args[2].GetTextValue().ToString(), "Ey up"); + } + TestEqual("Event name", Participant->EventRecords[2].Name.ToString(), "Calculated"); + if (TestEqual("Event sub arg count", Participant->EventRecords[2].Args.Num(), 3)) + { + TestEqual("Event sub arg 0 type", Participant->EventRecords[2].Args[0].GetType(), ESUDSValueType::Float); + TestEqual("Event sub arg 0 value", Participant->EventRecords[2].Args[0].GetFloatValue(), 76.67f); + TestEqual("Event sub arg 2 type", Participant->EventRecords[2].Args[1].GetType(), ESUDSValueType::Boolean); + TestEqual("Event sub arg 2 value", Participant->EventRecords[2].Args[1].GetBooleanValue(), true); + TestEqual("Event sub arg 2 type", Participant->EventRecords[2].Args[2].GetType(), ESUDSValueType::Text); + TestEqual("Event sub arg 2 value", Participant->EventRecords[2].Args[2].GetTextValue().ToString(), "Ey up"); + } + TestEqual("Event name", EvtSub->EventRecords[3].Name.ToString(), "Actor.Emote"); + if (TestEqual("Event sub arg count", EvtSub->EventRecords[3].Args.Num(), 2)) + { + TestEqual("Event sub arg 0 type", EvtSub->EventRecords[3].Args[0].GetType(), ESUDSValueType::Name); + TestEqual("Event sub arg 0 value", EvtSub->EventRecords[3].Args[0].GetNameValue(), FName("Player")); + TestEqual("Event sub arg 2 type", EvtSub->EventRecords[3].Args[1].GetType(), ESUDSValueType::Name); + TestEqual("Event sub arg 2 value", EvtSub->EventRecords[3].Args[1].GetNameValue(), FName("Question")); + } + } + + Script->MarkAsGarbage(); + return true; +} + +UE_ENABLE_OPTIMIZATION \ No newline at end of file diff --git a/Plugins/SUDS/Source/SUDSTest/Private/TestExpressions.cpp b/Plugins/SUDS/Source/SUDSTest/Private/TestExpressions.cpp new file mode 100644 index 00000000..2ec718b6 --- /dev/null +++ b/Plugins/SUDS/Source/SUDSTest/Private/TestExpressions.cpp @@ -0,0 +1,209 @@ +#include "SUDSExpression.h" +#include "Misc/AutomationTest.h" + +UE_DISABLE_OPTIMIZATION + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestExpressions, + "SUDSTest.TestExpressions", + EAutomationTestFlags::EditorContext | + EAutomationTestFlags::ClientContext | + EAutomationTestFlags::ProductFilter) + + + +bool FTestExpressions::RunTest(const FString& Parameters) +{ + FSUDSExpression Expr; + TMap Variables; + TMap GlobalVariables; + + Variables.Add("Six", 6); + TestTrue("SimpleVarParse", Expr.ParseFromString("3 + 4 * {Six} + 1", nullptr)); + TestEqual("Eval", Expr.Evaluate(Variables, GlobalVariables).GetIntValue(), 28); + + auto& RPN = Expr.GetQueue(); + if (TestEqual("Queue len", RPN.Num(), 7)) + { + // RPN should be 3 4 6 * + 1 + + TestEqual("Queue 0", RPN[0].GetOperandValue().GetIntValue(), 3); + TestEqual("Queue 1", RPN[1].GetOperandValue().GetIntValue(), 4); + TestEqual("Queue 2", RPN[2].GetOperandValue().GetVariableNameValue().ToString(), "Six"); + TestEqual("Queue 3", RPN[3].GetType(), ESUDSExpressionItemType::Multiply); + TestEqual("Queue 4", RPN[4].GetType(), ESUDSExpressionItemType::Add); + TestEqual("Queue 5", RPN[5].GetOperandValue().GetIntValue(), 1); + TestEqual("Queue 6", RPN[6].GetType(), ESUDSExpressionItemType::Add); + } + if (TestEqual("Variable count", Expr.GetVariableNames().Num(), 1)) + { + TestEqual("Variable name", Expr.GetVariableNames()[0].ToString(), "Six"); + } + + TestTrue("Arithmetic", Expr.ParseFromString("-6.7 * 2 + (21.3 - 8) * 5", nullptr)); + TestEqual("Eval", Expr.Evaluate(Variables, GlobalVariables).GetFloatValue(), 53.1f); + + // Modulo operator + TestTrue("ModuloIntOperator", Expr.ParseFromString("11 % 5", nullptr)); + TestEqual("Eval", Expr.Evaluate(Variables, GlobalVariables).GetIntValue(), 1); + TestTrue("ModuloFloatOperator", Expr.ParseFromString("7.25 % 3.0", nullptr)); + TestEqual("Eval", Expr.Evaluate(Variables, GlobalVariables).GetFloatValue(), 1.25f); + + // Explicit FSUDSValue(true) needed to avoid it using the int conversion by default + Variables.Add("IsATest", FSUDSValue(true)); + TestTrue("BoolSingleValueParse", Expr.ParseFromString("{IsATest}", nullptr)); + TestTrue("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue()); + if (TestEqual("Variable count", Expr.GetVariableNames().Num(), 1)) + { + TestEqual("Variable name", Expr.GetVariableNames()[0].ToString(), "IsATest"); + } + + Variables.Add("SomethingFalse", FSUDSValue(false)); + Variables.Add("SomethingTrue", FSUDSValue(true)); + Variables.Add("SomethingElseFalse", FSUDSValue(false)); + TestTrue("BoolCompound1", Expr.ParseFromString("!{SomethingFalse} && {SomethingTrue}", nullptr)); + TestTrue("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue()); + if (TestEqual("Variable count", Expr.GetVariableNames().Num(), 2)) + { + TestEqual("Variable name", Expr.GetVariableNames()[0].ToString(), "SomethingFalse"); + TestEqual("Variable name", Expr.GetVariableNames()[1].ToString(), "SomethingTrue"); + } + TestTrue("BoolCompound2", Expr.ParseFromString("{SomethingFalse} || {SomethingTrue}", nullptr)); + TestTrue("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue()); + TestTrue("BoolCompound3", Expr.ParseFromString("{SomethingFalse} or {SomethingTrue}", nullptr)); + TestTrue("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue()); + // Test parentheses changing precedence & result + // True result for successful parsing, but false for Eval unless we parenthesise + TestTrue("BoolCompound4", Expr.ParseFromString("!{SomethingFalse} && {SomethingElseFalse} && {SomethingTrue}", nullptr)); + TestFalse("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue()); + if (TestEqual("Variable count", Expr.GetVariableNames().Num(), 3)) + { + TestEqual("Variable name", Expr.GetVariableNames()[0].ToString(), "SomethingFalse"); + TestEqual("Variable name", Expr.GetVariableNames()[1].ToString(), "SomethingElseFalse"); + TestEqual("Variable name", Expr.GetVariableNames()[2].ToString(), "SomethingTrue"); + } + TestTrue("BoolCompound5", Expr.ParseFromString("!({SomethingFalse} && {SomethingElseFalse}) && {SomethingTrue}", nullptr)); + TestTrue("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue()); + TestTrue("BoolCompound6", Expr.ParseFromString("not {SomethingFalse} and {SomethingElseFalse} and {SomethingTrue}", nullptr)); + TestFalse("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue()); + TestTrue("BoolCompound7", Expr.ParseFromString("not ({SomethingFalse} and {SomethingElseFalse}) and {SomethingTrue}", nullptr)); + TestTrue("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue()); + + Variables.Add("Seven", 7); + TestTrue("Comparisons", Expr.ParseFromString("{Six} == 6", nullptr)); + TestTrue("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue()); + TestTrue("Comparisons", Expr.ParseFromString("{Six} = 6", nullptr)); + TestTrue("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue()); + TestTrue("Comparisons", Expr.ParseFromString("{Six} >= 6", nullptr)); + TestTrue("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue()); + TestTrue("Comparisons", Expr.ParseFromString("{Six} > 6", nullptr)); + TestFalse("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue()); + TestTrue("Comparisons", Expr.ParseFromString("{Six} < 6", nullptr)); + TestFalse("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue()); + TestTrue("Comparisons", Expr.ParseFromString("{Six} <= 6", nullptr)); + TestTrue("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue()); + TestTrue("Comparisons", Expr.ParseFromString("{Six} < {Seven}", nullptr)); + TestTrue("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue()); + TestTrue("Comparisons", Expr.ParseFromString("{Seven} > {Six}", nullptr)); + TestTrue("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue()); + TestTrue("Comparisons", Expr.ParseFromString("{Seven} != {Six}", nullptr)); + TestTrue("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue()); + TestTrue("Comparisons", Expr.ParseFromString("{Seven} != {Seven}", nullptr)); + TestFalse("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue()); + + // Mixed float/int comparisons + Variables.Add("EightFloat", 8.1f); + TestTrue("Comparisons", Expr.ParseFromString("{EightFloat} > 8", nullptr)); + TestTrue("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue()); + TestTrue("Comparisons", Expr.ParseFromString("{EightFloat} > {Seven}", nullptr)); + TestTrue("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue()); + TestTrue("Comparisons", Expr.ParseFromString("{Six} < {EightFloat}", nullptr)); + TestTrue("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue()); + // Fuzzy float comparisons + Variables.Add("EightFloatPlusMargin", 8.1000002f); + TestTrue("Comparisons", Expr.ParseFromString("{EightFloat} == 8.1", nullptr)); + TestTrue("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue()); + TestTrue("Comparisons", Expr.ParseFromString("{EightFloat} == 8.15", nullptr)); + TestFalse("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue()); + TestTrue("Comparisons", Expr.ParseFromString("{EightFloatPlusMargin} == 8.1", nullptr)); + TestTrue("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue()); + TestTrue("Comparisons", Expr.ParseFromString("{EightFloat} == 8.1000005", nullptr)); + TestTrue("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue()); + TestTrue("Comparisons", Expr.ParseFromString("{EightFloatPlusMargin} == 8.1000005", nullptr)); + TestTrue("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue()); + + // Other type comparisons + Variables.Add("SomeText", FText::FromString("Hello")); + TestTrue("Comparisons", Expr.ParseFromString("{SomeText} == \"Hello\"", nullptr)); + TestTrue("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue()); + TestTrue("Comparisons", Expr.ParseFromString("{SomeText} == \"Hi\"", nullptr)); + TestFalse("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue()); + + Variables.Add("Male", ETextGender::Masculine); + Variables.Add("Female", ETextGender::Feminine); + Variables.Add("AlsoFemale", ETextGender::Feminine); + Variables.Add("Neuter", ETextGender::Neuter); + TestTrue("Comparisons", Expr.ParseFromString("{Male} == masculine", nullptr)); + TestTrue("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue()); + TestTrue("Comparisons", Expr.ParseFromString("{Male} == Masculine", nullptr)); + TestTrue("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue()); + TestTrue("Comparisons", Expr.ParseFromString("{Male} == feminine", nullptr)); + TestFalse("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue()); + TestTrue("Comparisons", Expr.ParseFromString("{Male} == Feminine", nullptr)); + TestFalse("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue()); + TestTrue("Comparisons", Expr.ParseFromString("{Female} == Feminine", nullptr)); + TestTrue("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue()); + TestTrue("Comparisons", Expr.ParseFromString("{Female} != feminine", nullptr)); + TestFalse("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue()); + TestTrue("Comparisons", Expr.ParseFromString("{Female} == {AlsoFemale}", nullptr)); + TestTrue("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue()); + TestTrue("Comparisons", Expr.ParseFromString("{Female} != {Neuter}", nullptr)); + TestTrue("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue()); + TestTrue("Comparisons", Expr.ParseFromString("{Neuter} == neuter", nullptr)); + TestTrue("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue()); + TestTrue("Comparisons", Expr.ParseFromString("{Neuter} == Neuter", nullptr)); + TestTrue("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue()); + + // Test local / global by defining the same variable + GlobalVariables.Add("GlobalLocalTestInt", 3); + Variables.Add("GlobalLocalTestInt", 20); + TestTrue("LocalTest", Expr.ParseFromString("{GlobalLocalTestInt} == 20", nullptr)); + TestTrue("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue()); + TestTrue("GlobalTest", Expr.ParseFromString("{global.GlobalLocalTestInt} == 3", nullptr)); + TestTrue("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue()); + + + + return true; +}; + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestBadExpressions, + "SUDSTest.TestBadExpressions", + EAutomationTestFlags::EditorContext | + EAutomationTestFlags::ClientContext | + EAutomationTestFlags::ProductFilter) + + + +bool FTestBadExpressions::RunTest(const FString& Parameters) +{ + FSUDSExpression Expr; + TMap Variables; + + FString ParseError; + + TestFalse("Missing operand", Expr.ParseFromString(" + 1", &ParseError)); + TestTrue("Correct error", ParseError.Contains("Bad expression")); + TestFalse("Missing operand", Expr.ParseFromString("1 * ", &ParseError)); + TestTrue("Correct error", ParseError.Contains("Bad expression")); + TestFalse("Missing parenthesis", Expr.ParseFromString("(3 + 1", &ParseError)); + TestTrue("Correct error", ParseError.Contains("Mismatched parentheses")); + TestFalse("Missing parenthesis", Expr.ParseFromString("3 + 1)", &ParseError)); + TestTrue("Correct error", ParseError.Contains("Mismatched parentheses")); + TestFalse("Invalid symbol", Expr.ParseFromString("something + 1", &ParseError)); + TestTrue("Correct error", ParseError.Contains("Bad expression")); + + return true; +} + + + +UE_ENABLE_OPTIMIZATION \ No newline at end of file diff --git a/Plugins/SUDS/Source/SUDSTest/Private/TestGotoGosub.cpp b/Plugins/SUDS/Source/SUDSTest/Private/TestGotoGosub.cpp new file mode 100644 index 00000000..33a86361 --- /dev/null +++ b/Plugins/SUDS/Source/SUDSTest/Private/TestGotoGosub.cpp @@ -0,0 +1,417 @@ +#include "SUDSDialogue.h" +#include "SUDSLibrary.h" +#include "SUDSMessageLogger.h" +#include "SUDSScript.h" +#include "SUDSScriptImporter.h" +#include "TestUtils.h" +#include "Misc/AutomationTest.h" + +UE_DISABLE_OPTIMIZATION + +const FString GotoGosubInput = R"RAWSUD( +Player: Hello there +NPC: Hello + :choice + * Actually bye + NPC: How rude + [goto end] + * Reused + NPC: This is going to re-used dialogue + [gosub subroutine] + NPC: And now we're back + [goto goodbye] + * Gosub choice + NPC: This is going to return to the choice + [gosub subroutine] + * Choice After + NPC: It's a choice after a gosub! + [goto goodbye] + * Choice After 2 + NPC: Not really much difference eh + [goto goodbye] + * Gosub choice via goto + NPC: This is going to return to the choice via goto + [gosub subroutine] + [goto choice] + +:subroutine +Player: Some reused discussion +NPC: Yep, sure is +[return] +:goodbye +NPC: Bye! +)RAWSUD"; + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestGotoGosub, + "SUDSTest.TestGotoGosub", + EAutomationTestFlags::EditorContext | + EAutomationTestFlags::ClientContext | + EAutomationTestFlags::ProductFilter) + + + +bool FTestGotoGosub::RunTest(const FString& Parameters) +{ + FSUDSMessageLogger Logger(false); + FSUDSScriptImporter Importer; + TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(GotoGosubInput), GotoGosubInput.Len(), "GotoGosubInput", &Logger, true)); + + auto Script = NewObject(GetTransientPackage(), "Test"); + const ScopedStringTableHolder StringTableHolder; + Importer.PopulateAsset(Script, StringTableHolder.StringTable); + + // Script shouldn't be the owner of the dialogue but it's the only UObject we've got right now so why not + auto Dlg = USUDSLibrary::CreateDialogue(Script, Script); + Dlg->Start(); + + + TestDialogueText(this, "Start node", Dlg, "Player", "Hello there"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Next", Dlg, "NPC", "Hello"); + TestEqual("Choices", Dlg->GetNumberOfChoices(), 4); + TestEqual("Choice 0 text", Dlg->GetChoiceText(0).ToString(), "Actually bye"); + TestTrue("Choose 0", Dlg->Choose(0)); + TestDialogueText(this, "Next", Dlg, "NPC", "How rude"); + TestFalse("End", Dlg->Continue()); + + Dlg->Restart(); + TestDialogueText(this, "Start node", Dlg, "Player", "Hello there"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Next", Dlg, "NPC", "Hello"); + TestTrue("Choose 1", Dlg->Choose(1)); + TestDialogueText(this, "Next", Dlg, "NPC", "This is going to re-used dialogue"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Next", Dlg, "Player", "Some reused discussion"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Next", Dlg, "NPC", "Yep, sure is"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Next", Dlg, "NPC", "And now we're back"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Next", Dlg, "NPC", "Bye!"); + TestFalse("Continue", Dlg->Continue()); + + Dlg->Restart(); + TestDialogueText(this, "Start node", Dlg, "Player", "Hello there"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Next", Dlg, "NPC", "Hello"); + TestTrue("Choose 2", Dlg->Choose(2)); + TestDialogueText(this, "Next", Dlg, "NPC", "This is going to return to the choice"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Next", Dlg, "Player", "Some reused discussion"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Next", Dlg, "NPC", "Yep, sure is"); + // Should have a choice at the end of it this time + TestEqual("Choices", Dlg->GetNumberOfChoices(), 2); + TestEqual("Choice 0 text", Dlg->GetChoiceText(0).ToString(), "Choice After"); + TestEqual("Choice 1 text", Dlg->GetChoiceText(1).ToString(), "Choice After 2"); + TestTrue("Choose 1", Dlg->Choose(1)); + TestDialogueText(this, "Next", Dlg, "NPC", "Not really much difference eh"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Next", Dlg, "NPC", "Bye!"); + TestFalse("Continue", Dlg->Continue()); + + Dlg->Restart(); + TestDialogueText(this, "Start node", Dlg, "Player", "Hello there"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Next", Dlg, "NPC", "Hello"); + TestTrue("Choose 2", Dlg->Choose(3)); + TestDialogueText(this, "Next", Dlg, "NPC", "This is going to return to the choice via goto"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Next", Dlg, "Player", "Some reused discussion"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Next", Dlg, "NPC", "Yep, sure is"); + // Should have a choice at the end of it this time + TestEqual("Choices", Dlg->GetNumberOfChoices(), 4); + TestEqual("Choice 0 text", Dlg->GetChoiceText(0).ToString(), "Actually bye"); + TestTrue("Choose 0", Dlg->Choose(0)); + TestDialogueText(this, "Next", Dlg, "NPC", "How rude"); + TestFalse("End", Dlg->Continue()); + + Script->MarkAsGarbage(); + return true; +} + + +const FString NestedGosubInput = R"RAWSUD( +Player: Hello there +[gosub sub1] +NPC: Back at level 0 +[goto goodbye] + +:sub1 +Player: This is level 1 +[gosub sub2_1] +[gosub sub2_2] +Player: End of level 1 +[return] + +:sub2_1 +Player: This is level 2, sub 1 +[return] + + +:sub2_2 +Player: This is level 2, sub 2 +NPC: We have to go deeper +[gosub sub3] +Player: End of level 2, sub 2 +[return] + +:sub3 +Player: This is level 3 +[return] + +:goodbye +NPC: Bye! +)RAWSUD"; + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestNestedGosub, + "SUDSTest.TestNestedGosub", + EAutomationTestFlags::EditorContext | + EAutomationTestFlags::ClientContext | + EAutomationTestFlags::ProductFilter) + + + +bool FTestNestedGosub::RunTest(const FString& Parameters) +{ + FSUDSMessageLogger Logger(false); + FSUDSScriptImporter Importer; + TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(NestedGosubInput), NestedGosubInput.Len(), "NestedGosubInput", &Logger, true)); + + auto Script = NewObject(GetTransientPackage(), "Test"); + const ScopedStringTableHolder StringTableHolder; + Importer.PopulateAsset(Script, StringTableHolder.StringTable); + + // Script shouldn't be the owner of the dialogue but it's the only UObject we've got right now so why not + auto Dlg = USUDSLibrary::CreateDialogue(Script, Script); + Dlg->Start(); + + + TestDialogueText(this, "Start node", Dlg, "Player", "Hello there"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Sub", Dlg, "Player", "This is level 1"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Sub", Dlg, "Player", "This is level 2, sub 1"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Sub", Dlg, "Player", "This is level 2, sub 2"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Sub", Dlg, "NPC", "We have to go deeper"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Sub", Dlg, "Player", "This is level 3"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Sub", Dlg, "Player", "End of level 2, sub 2"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Sub", Dlg, "Player", "End of level 1"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Sub", Dlg, "NPC", "Back at level 0"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Bye", Dlg, "NPC", "Bye!"); + TestFalse("Continue", Dlg->Continue()); + + Script->MarkAsGarbage(); + return true; +} + + + +const FString GotoBetweenSpeakerAndChoiceInput = R"RAWSUD( +Player: Hello there +[if {SkipChoices}] + [goto end] +[endif] +* Option A +* Option B +)RAWSUD"; + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestGotoBetweenSpeakerAndChoice1, + "SUDSTest.TestGotoBetweenSpeakerAndChoice1", + EAutomationTestFlags::EditorContext | + EAutomationTestFlags::ClientContext | + EAutomationTestFlags::ProductFilter) + + + +bool FTestGotoBetweenSpeakerAndChoice1::RunTest(const FString& Parameters) +{ + FSUDSMessageLogger Logger(false); + FSUDSScriptImporter Importer; + TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(GotoBetweenSpeakerAndChoiceInput), GotoBetweenSpeakerAndChoiceInput.Len(), "GotoBetweenSpeakerAndChoiceInput", &Logger, true)); + + auto Script = NewObject(GetTransientPackage(), "Test"); + const ScopedStringTableHolder StringTableHolder; + Importer.PopulateAsset(Script, StringTableHolder.StringTable); + + // Script shouldn't be the owner of the dialogue but it's the only UObject we've got right now so why not + auto Dlg = USUDSLibrary::CreateDialogue(Script, Script); + Dlg->Start(); + + + if (TestEqual("Choice Count", Dlg->GetNumberOfChoices(), 2)) + { + TestEqual("Choice 1", Dlg->GetChoiceText(0).ToString(), "Option A"); + TestEqual("Choice 2", Dlg->GetChoiceText(1).ToString(), "Option B"); + } + + Script->MarkAsGarbage(); + return true; +} + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestGotoBetweenSpeakerAndChoice2, + "SUDSTest.TestGotoBetweenSpeakerAndChoice2", + EAutomationTestFlags::EditorContext | + EAutomationTestFlags::ClientContext | + EAutomationTestFlags::ProductFilter) + + + +bool FTestGotoBetweenSpeakerAndChoice2::RunTest(const FString& Parameters) +{ + FSUDSMessageLogger Logger(false); + FSUDSScriptImporter Importer; + TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(GotoBetweenSpeakerAndChoiceInput), GotoBetweenSpeakerAndChoiceInput.Len(), "GotoBetweenSpeakerAndChoiceInput", &Logger, true)); + + auto Script = NewObject(GetTransientPackage(), "Test"); + const ScopedStringTableHolder StringTableHolder; + Importer.PopulateAsset(Script, StringTableHolder.StringTable); + + // Script shouldn't be the owner of the dialogue but it's the only UObject we've got right now so why not + auto Dlg = USUDSLibrary::CreateDialogue(Script, Script); + Dlg->SetVariableBoolean("SkipChoices", true); + Dlg->Start(); + + + TestDialogueText(this, "Start node", Dlg, "Player", "Hello there"); + TestTrue("Plain continue", Dlg->IsSimpleContinue()); + + Script->MarkAsGarbage(); + return true; +} + + +const FString GosubBetweenSpeakerAndChoiceInput1 = R"RAWSUD( +Player: Hello there +[gosub MaybeSkipChoices] +* Option A +* Option B +[goto end] + +:MaybeSkipChoices +[if {SkipChoices}] + [goto end] +[endif] +[return] +)RAWSUD"; + +const FString GosubBetweenSpeakerAndChoiceInput2 = R"RAWSUD( +Player: Hello there +[gosub PrintDebug] +[gosub MaybeSkipChoices] +* Option A +* Option B +[goto end] + +:PrintDebug +Debug: SkipChoices is {SkipChoices} +[return] + +:MaybeSkipChoices +[if {SkipChoices}] + [goto end] +[endif] +Speaker: Another sentence. +[return] +)RAWSUD"; + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestGosubBetweenSpeakerAndChoice1, + "SUDSTest.TestGosubBetweenSpeakerAndChoice1", + EAutomationTestFlags::EditorContext | + EAutomationTestFlags::ClientContext | + EAutomationTestFlags::ProductFilter) + + + +bool FTestGosubBetweenSpeakerAndChoice1::RunTest(const FString& Parameters) +{ + FSUDSMessageLogger Logger(false); + FSUDSScriptImporter Importer; + TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(GosubBetweenSpeakerAndChoiceInput1), GosubBetweenSpeakerAndChoiceInput1.Len(), "GosubBetweenSpeakerAndChoiceInput1", &Logger, true)); + + auto Script = NewObject(GetTransientPackage(), "Test"); + const ScopedStringTableHolder StringTableHolder; + Importer.PopulateAsset(Script, StringTableHolder.StringTable); + + // Script shouldn't be the owner of the dialogue but it's the only UObject we've got right now so why not + auto Dlg = USUDSLibrary::CreateDialogue(Script, Script); + Dlg->Start(); + + + TestDialogueText(this, "Start node", Dlg, "Player", "Hello there"); + if (TestEqual("Choice Count", Dlg->GetNumberOfChoices(), 2)) + { + TestEqual("Choice 1", Dlg->GetChoiceText(0).ToString(), "Option A"); + TestEqual("Choice 2", Dlg->GetChoiceText(1).ToString(), "Option B"); + } + + // Now test the true case + Dlg->SetVariableBoolean("SkipChoices", true); + Dlg->Restart(false); + TestDialogueText(this, "Start node", Dlg, "Player", "Hello there"); + TestTrue("Plain continue", Dlg->IsSimpleContinue()); + + Script->MarkAsGarbage(); + return true; +} + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestGosubBetweenSpeakerAndChoice2, + "SUDSTest.TestGosubBetweenSpeakerAndChoice2", + EAutomationTestFlags::EditorContext | + EAutomationTestFlags::ClientContext | + EAutomationTestFlags::ProductFilter) + + + +bool FTestGosubBetweenSpeakerAndChoice2::RunTest(const FString& Parameters) +{ + FSUDSMessageLogger Logger(false); + FSUDSScriptImporter Importer; + TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(GosubBetweenSpeakerAndChoiceInput2), GosubBetweenSpeakerAndChoiceInput2.Len(), "GosubBetweenSpeakerAndChoiceInput2", &Logger, true)); + + auto Script = NewObject(GetTransientPackage(), "Test"); + const ScopedStringTableHolder StringTableHolder; + Importer.PopulateAsset(Script, StringTableHolder.StringTable); + + // Script shouldn't be the owner of the dialogue but it's the only UObject we've got right now so why not + auto Dlg = USUDSLibrary::CreateDialogue(Script, Script); + Dlg->Start(); + + + TestDialogueText(this, "Start node", Dlg, "Player", "Hello there"); + // in this case, there's another speaker line in both nested gosubs so the choices are actually associated with the + // last of those. + TestTrue("Plain continue", Dlg->Continue()); + TestDialogueText(this, "Gosub 1 speaker node", Dlg, "Debug", "SkipChoices is {SkipChoices}"); + TestTrue("Plain continue", Dlg->Continue()); + TestDialogueText(this, "Gosub 2 speaker node", Dlg, "Speaker", "Another sentence."); + + if (TestEqual("Choice Count", Dlg->GetNumberOfChoices(), 2)) + { + TestEqual("Choice 1", Dlg->GetChoiceText(0).ToString(), "Option A"); + TestEqual("Choice 2", Dlg->GetChoiceText(1).ToString(), "Option B"); + } + + // Now test the true case + Dlg->SetVariableBoolean("SkipChoices", true); + Dlg->Restart(false); + TestDialogueText(this, "Start node", Dlg, "Player", "Hello there"); + TestTrue("Plain continue", Dlg->Continue()); + TestDialogueText(this, "Gosub 1 speaker node", Dlg, "Debug", "SkipChoices is 1"); + TestTrue("Plain continue", Dlg->IsSimpleContinue()); + + + Script->MarkAsGarbage(); + return true; +} + + +UE_ENABLE_OPTIMIZATION \ No newline at end of file diff --git a/Plugins/SUDS/Source/SUDSTest/Private/TestMetadata.cpp b/Plugins/SUDS/Source/SUDSTest/Private/TestMetadata.cpp new file mode 100644 index 00000000..2dab21e4 --- /dev/null +++ b/Plugins/SUDS/Source/SUDSTest/Private/TestMetadata.cpp @@ -0,0 +1,133 @@ +#include "SUDSDialogue.h" +#include "SUDSLibrary.h" +#include "SUDSMessageLogger.h" +#include "SUDSScript.h" +#include "SUDSScriptImporter.h" +#include "TestUtils.h" +#include "Internationalization/StringTableCore.h" +#include "Misc/AutomationTest.h" + +UE_DISABLE_OPTIMIZATION + +// Assign known keys to the strings so we can detect +const FString MetadataInput = R"RAWSUD( +# No metadata +Player: Hello there @001@ +#= Transient metadata for next line +#= TransientData: Something here +NPC: Hello @002@ +# Should have no metadata anymore +Player: Well this is nice @003@ +#+ Persistent metadata +#+ PersistentData: Something longer lived +NPC: Isn't it though @004@ +# Should still apply here +Player: Indeed @005@ +#= Test overriding temporarily +#= PersistentData: This should override +#= ExtraTransient: This should be new only for next line +NPC: Well well @006@ +Player: Metadata should have gone back now @007@ +#+ Persistent metadata changed now +Player: Persistent change @008@ + * Some choice @009@ + NPC: How rude @010@ + [goto end] + #= Temporary comment + * Another choice @011@ + NPC: This should be back now @012@ + #+ Persistent indented change 1 + * One more choice @013@ + #+ Even more indented + #+ NestedKey: This will disappear fast even though persistent + NPC: Ooops @014@ + * Final choice @015@ + NPC: This is going to return to the choice via goto @016@ +# Nested meta should have been lost but outer meta should be back +Player: Something something @017@ +)RAWSUD"; + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestMetadata, + "SUDSTest.TestMetadata", + EAutomationTestFlags::EditorContext | + EAutomationTestFlags::ClientContext | + EAutomationTestFlags::ProductFilter) + + + +bool FTestMetadata::RunTest(const FString& Parameters) +{ + FSUDSMessageLogger Logger(false); + FSUDSScriptImporter Importer; + TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(MetadataInput), MetadataInput.Len(), "MetadataInput", &Logger, true)); + + auto Script = NewObject(GetTransientPackage(), "Test"); + const ScopedStringTableHolder StringTableHolder; + Importer.PopulateAsset(Script, StringTableHolder.StringTable); + + // In this test we're only interested in the string table metadata + // Have to get the mutable string table to get at metadata + auto StrTable = StringTableHolder.StringTable->GetMutableStringTable(); + + TestEqual("Line 1 Comment", StrTable->GetMetaData(FTextKey("@001@"), FName("Comment")), ""); + TestEqual("Line 1 Speaker", StrTable->GetMetaData(FTextKey("@001@"), FName("Speaker")), "Player"); + TestEqual("Line 2 Comment", StrTable->GetMetaData(FTextKey("@002@"), FName("Comment")), "Transient metadata for next line"); + TestEqual("Line 2 Speaker", StrTable->GetMetaData(FTextKey("@002@"), FName("Speaker")), "NPC"); + TestEqual("Line 2 Custom", StrTable->GetMetaData(FTextKey("@002@"), FName("TransientData")), "Something here"); + TestEqual("Line 3 Comment (should have reset)", StrTable->GetMetaData(FTextKey("@003@"), FName("Comment")), ""); + TestEqual("Line 3 Speaker", StrTable->GetMetaData(FTextKey("@003@"), FName("Speaker")), "Player"); + TestEqual("Line 3 Custom (should have reset)", StrTable->GetMetaData(FTextKey("@003@"), FName("TransientData")), ""); + TestEqual("Line 4 Comment", StrTable->GetMetaData(FTextKey("@004@"), FName("Comment")), "Persistent metadata"); + TestEqual("Line 4 Speaker", StrTable->GetMetaData(FTextKey("@004@"), FName("Speaker")), "NPC"); + TestEqual("Line 4 Custom", StrTable->GetMetaData(FTextKey("@004@"), FName("PersistentData")), "Something longer lived"); + TestEqual("Line 5 Comment", StrTable->GetMetaData(FTextKey("@005@"), FName("Comment")), "Persistent metadata"); + TestEqual("Line 5 Speaker", StrTable->GetMetaData(FTextKey("@005@"), FName("Speaker")), "Player"); + TestEqual("Line 5 Custom", StrTable->GetMetaData(FTextKey("@005@"), FName("PersistentData")), "Something longer lived"); + TestEqual("Line 6 Comment", StrTable->GetMetaData(FTextKey("@006@"), FName("Comment")), "Test overriding temporarily"); + TestEqual("Line 6 Speaker", StrTable->GetMetaData(FTextKey("@006@"), FName("Speaker")), "NPC"); + TestEqual("Line 6 Custom", StrTable->GetMetaData(FTextKey("@006@"), FName("PersistentData")), "This should override"); + TestEqual("Line 6 Custom 2", StrTable->GetMetaData(FTextKey("@006@"), FName("ExtraTransient")), "This should be new only for next line"); + TestEqual("Line 7 Comment", StrTable->GetMetaData(FTextKey("@007@"), FName("Comment")), "Persistent metadata"); + TestEqual("Line 7 Speaker", StrTable->GetMetaData(FTextKey("@007@"), FName("Speaker")), "Player"); + TestEqual("Line 7 Custom", StrTable->GetMetaData(FTextKey("@007@"), FName("PersistentData")), "Something longer lived"); + TestEqual("Line 8 Comment", StrTable->GetMetaData(FTextKey("@008@"), FName("Comment")), "Persistent metadata changed now"); + TestEqual("Line 8 Speaker", StrTable->GetMetaData(FTextKey("@008@"), FName("Speaker")), "Player"); + TestEqual("Line 8 Custom", StrTable->GetMetaData(FTextKey("@008@"), FName("PersistentData")), "Something longer lived"); + TestEqual("Line 9 Comment", StrTable->GetMetaData(FTextKey("@009@"), FName("Comment")), "Persistent metadata changed now"); + TestEqual("Line 9 Speaker", StrTable->GetMetaData(FTextKey("@009@"), FName("Speaker")), "Player (Choice)"); + TestEqual("Line 9 Custom", StrTable->GetMetaData(FTextKey("@009@"), FName("PersistentData")), "Something longer lived"); + TestEqual("Line 10 Comment", StrTable->GetMetaData(FTextKey("@010@"), FName("Comment")), "Persistent metadata changed now"); + TestEqual("Line 10 Speaker", StrTable->GetMetaData(FTextKey("@010@"), FName("Speaker")), "NPC"); + TestEqual("Line 10 Custom", StrTable->GetMetaData(FTextKey("@010@"), FName("PersistentData")), "Something longer lived"); + TestEqual("Line 11 Comment", StrTable->GetMetaData(FTextKey("@011@"), FName("Comment")), "Temporary comment"); + TestEqual("Line 11 Speaker", StrTable->GetMetaData(FTextKey("@011@"), FName("Speaker")), "Player (Choice)"); + TestEqual("Line 11 Custom", StrTable->GetMetaData(FTextKey("@011@"), FName("PersistentData")), "Something longer lived"); + TestEqual("Line 12 Comment", StrTable->GetMetaData(FTextKey("@012@"), FName("Comment")), "Persistent metadata changed now"); + TestEqual("Line 12 Speaker", StrTable->GetMetaData(FTextKey("@012@"), FName("Speaker")), "NPC"); + TestEqual("Line 12 Custom", StrTable->GetMetaData(FTextKey("@012@"), FName("PersistentData")), "Something longer lived"); + TestEqual("Line 13 Comment", StrTable->GetMetaData(FTextKey("@013@"), FName("Comment")), "Persistent indented change 1"); + TestEqual("Line 13 Speaker", StrTable->GetMetaData(FTextKey("@013@"), FName("Speaker")), "Player (Choice)"); + TestEqual("Line 13 Custom", StrTable->GetMetaData(FTextKey("@013@"), FName("PersistentData")), "Something longer lived"); + TestEqual("Line 14 Comment", StrTable->GetMetaData(FTextKey("@014@"), FName("Comment")), "Even more indented"); + TestEqual("Line 14 Speaker", StrTable->GetMetaData(FTextKey("@014@"), FName("Speaker")), "NPC"); + TestEqual("Line 14 Custom", StrTable->GetMetaData(FTextKey("@014@"), FName("PersistentData")), "Something longer lived"); + TestEqual("Line 14 Custom 2", StrTable->GetMetaData(FTextKey("@014@"), FName("NestedKey")), "This will disappear fast even though persistent"); + TestEqual("Line 15 Comment", StrTable->GetMetaData(FTextKey("@015@"), FName("Comment")), "Persistent indented change 1"); // should have gone back + TestEqual("Line 15 Speaker", StrTable->GetMetaData(FTextKey("@015@"), FName("Speaker")), "Player (Choice)"); + TestEqual("Line 15 Custom", StrTable->GetMetaData(FTextKey("@015@"), FName("PersistentData")), "Something longer lived"); + TestEqual("Line 15 Custom 2", StrTable->GetMetaData(FTextKey("@015@"), FName("NestedKey")), ""); + TestEqual("Line 16 Comment", StrTable->GetMetaData(FTextKey("@016@"), FName("Comment")), "Persistent indented change 1"); // should have gone back + TestEqual("Line 16 Speaker", StrTable->GetMetaData(FTextKey("@016@"), FName("Speaker")), "NPC"); + TestEqual("Line 16 Custom", StrTable->GetMetaData(FTextKey("@016@"), FName("PersistentData")), "Something longer lived"); + TestEqual("Line 16 Custom 2", StrTable->GetMetaData(FTextKey("@016@"), FName("NestedKey")), ""); + TestEqual("Line 17 Comment", StrTable->GetMetaData(FTextKey("@017@"), FName("Comment")), "Persistent metadata changed now"); // should have gone back to top level + TestEqual("Line 17 Speaker", StrTable->GetMetaData(FTextKey("@017@"), FName("Speaker")), "Player"); + TestEqual("Line 17 Custom", StrTable->GetMetaData(FTextKey("@017@"), FName("PersistentData")), "Something longer lived"); + + + Script->MarkAsGarbage(); + return true; + +} + +UE_ENABLE_OPTIMIZATION diff --git a/Plugins/SUDS/Source/SUDSTest/Private/TestParameters.cpp b/Plugins/SUDS/Source/SUDSTest/Private/TestParameters.cpp new file mode 100644 index 00000000..254f6154 --- /dev/null +++ b/Plugins/SUDS/Source/SUDSTest/Private/TestParameters.cpp @@ -0,0 +1,147 @@ +#include "SUDSDialogue.h" +#include "SUDSLibrary.h" +#include "SUDSMessageLogger.h" +#include "SUDSScript.h" +#include "SUDSScriptImporter.h" +#include "TestParticipant.h" +#include "TestUtils.h" +#include "Internationalization/Internationalization.h" +#include "Misc/AutomationTest.h" + +UE_DISABLE_OPTIMIZATION + +const FString ParamsInput = R"RAWSUD( +Player: Hello, I'm {SpeakerName.Player} +NPC: Greetings, {SpeakerName.Player}, my name is {SpeakerName.NPC} +Player: My friend's name is {FriendName}, {Gender}|gender(he,she,they) {Gender}|gender(has,has,have) {NumCats} {NumCats}|plural(one=cat,other=cats) +NPC: Floating point {FloatVal} format test +Player: Boolean test {BoolVal}? + * Choose, {SpeakerName.Player}! + * Is {NumCats} {NumCats}|plural(one=cat,other=cats) too many? + NPC: No, {numcats} is fine +)RAWSUD"; + + + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestParameters, + "SUDSTest.TestParameters", + EAutomationTestFlags::EditorContext | + EAutomationTestFlags::ClientContext | + EAutomationTestFlags::ProductFilter) + + + +bool FTestParameters::RunTest(const FString& Parameters) +{ + // Number and plural formatting are locale-specific, so we must set it to a predefined value to produce the expected output + FInternationalization::FCultureStateSnapshot CultureStateSnapshot; + FInternationalization::Get().BackupCultureState(CultureStateSnapshot); + FInternationalization::Get().SetCurrentCulture(TEXT("en-US")); + ON_SCOPE_EXIT + { + FInternationalization::Get().RestoreCultureState(CultureStateSnapshot); + }; + + FSUDSMessageLogger Logger(false); + FSUDSScriptImporter Importer; + TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(ParamsInput), ParamsInput.Len(), "ParamsInput", &Logger, true)); + + auto Script = NewObject(GetTransientPackage(), "Test"); + const ScopedStringTableHolder StringTableHolder; + Importer.PopulateAsset(Script, StringTableHolder.StringTable); + + // Script shouldn't be the owner of the dialogue but it's the only UObject we've got right now so why not + auto Dlg = USUDSLibrary::CreateDialogue(Script, Script); + auto Participant = NewObject(); + Participant->TestNumber = 0; + Dlg->AddParticipant(Participant); + Dlg->Start(); + + TestDialogueText(this, "Line 1", Dlg, "Player", "Hello, I'm Protagonist"); + Dlg->Continue(); + TestDialogueText(this, "Line 2", Dlg, "NPC", "Greetings, Protagonist, my name is An NPC"); + Dlg->Continue(); + TestDialogueText(this, "Line 3", Dlg, "Player", "My friend's name is Susan, she has 3 cats"); + Dlg->Continue(); + TestDialogueText(this, "Line 4", Dlg, "NPC", "Floating point 12.567 format test"); + Dlg->Continue(); + TestDialogueText(this, "Line 5", Dlg, "Player", "Boolean test 1?"); + if (TestEqual("Number of choices", Dlg->GetNumberOfChoices(), 2)) + { + TestEqual("Choice text 1", Dlg->GetChoiceText(0).ToString(), "Choose, Protagonist!"); + TestEqual("Choice text 2", Dlg->GetChoiceText(1).ToString(), "Is 3 cats too many?"); + + } + + // test case insensitivity, this next line uses {numcats} instead of {NumCats} + Dlg->Choose(1); + TestDialogueText(this, "Line 6", Dlg, "NPC", "No, 3 is fine"); + + Script->MarkAsGarbage(); + return true; +} + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestParametersPriority, + "SUDSTest.TestParametersPriority", + EAutomationTestFlags::EditorContext | + EAutomationTestFlags::ClientContext | + EAutomationTestFlags::ProductFilter) + + + +bool FTestParametersPriority::RunTest(const FString& Parameters) +{ + // Number and plural formatting are locale-specific, so we must set it to a predefined value to produce the expected output + FInternationalization::FCultureStateSnapshot CultureStateSnapshot; + FInternationalization::Get().BackupCultureState(CultureStateSnapshot); + FInternationalization::Get().SetCurrentCulture(TEXT("en-US")); + ON_SCOPE_EXIT + { + FInternationalization::Get().RestoreCultureState(CultureStateSnapshot); + }; + + FSUDSMessageLogger Logger(false); + FSUDSScriptImporter Importer; + TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(ParamsInput), ParamsInput.Len(), "ParamsInput", &Logger, true)); + + auto Script = NewObject(GetTransientPackage(), "Test"); + const ScopedStringTableHolder StringTableHolder; + Importer.PopulateAsset(Script, StringTableHolder.StringTable); + + // Script shouldn't be the owner of the dialogue but it's the only UObject we've got right now so why not + auto Dlg = USUDSLibrary::CreateDialogue(Script, Script); + auto Participant1 = NewObject(); + Participant1->TestNumber = 0; // priority 0 + auto Participant2 = NewObject(); + Participant2->TestNumber = 1; // priority 100 + auto Participant3 = NewObject(); + Participant1->TestNumber = 2; // priority -200 + Dlg->AddParticipant(Participant1); + Dlg->AddParticipant(Participant2); + Dlg->AddParticipant(Participant3); + Dlg->Start(); + + // Ordering of the participants should be from low to high, so variables from higher priority value participant should + // have overridden lower priority ones + + // All of these are set by Participant 2 who is higher priority + TestDialogueText(this, "Line 1", Dlg, "Player", "Hello, I'm Hero"); + Dlg->Continue(); + TestDialogueText(this, "Line 2", Dlg, "NPC", "Greetings, Hero, my name is Bob The NPC"); + Dlg->Continue(); + TestDialogueText(this, "Line 3", Dlg, "Player", "My friend's name is Derek, he has 5 cats"); + Dlg->Continue(); + + // These are set by Participant1 and unchanged by 2. 3 has tried to set float but should have been overridden + TestDialogueText(this, "Line 4", Dlg, "NPC", "Floating point 12.567 format test"); + Dlg->Continue(); + TestDialogueText(this, "Line 5", Dlg, "Player", "Boolean test 1?"); + + // Check that there's a variable from Participant2 which no-one else set + TestEqual("Participant3 should have set something", Dlg->GetVariableInt("SomethingUniqueTo3"), 120); + + Script->MarkAsGarbage(); + return true; +} + +UE_ENABLE_OPTIMIZATION \ No newline at end of file diff --git a/Plugins/SUDS/Source/SUDSTest/Private/TestParsing.cpp b/Plugins/SUDS/Source/SUDSTest/Private/TestParsing.cpp new file mode 100644 index 00000000..ed22b5f3 --- /dev/null +++ b/Plugins/SUDS/Source/SUDSTest/Private/TestParsing.cpp @@ -0,0 +1,1189 @@ +#include "SUDSMessageLogger.h" +#include "SUDSScript.h" +#include "Misc/AutomationTest.h" +#include "SUDSScriptImporter.h" +#include "SUDSScriptNode.h" +#include "TestUtils.h" + +UE_DISABLE_OPTIMIZATION + +const FString SimpleParsingInput = R"RAWSUD( +=== +# Nothing in header but a comment +=== + +# A comment in body + +Player: Excuse me? +NPC: Well, hello there. This is a test. + * A test? + NPC: Yes, a test. This is some indented continuation text. + Player: Oh I see, thank you. + NPC: You're welcome. + * Another option + NPC: This is another option with an embedded choice. + * How far can this go? + NPC: Theoretically forever but who knows? + * This is an extra question + NPC: That should have been added to the previous choice + * Another question? + NPC: Yep, this one too + * A third level of questions? + NPC: Yes, really! + * Wow + NPC: IKR + * Continuation with no response, just fallthrough + Player: This is a level 2 fallthrough +Player: Well, that's all for now. This should appear for all paths as a fall-through. + This, in fact, is a multi-line piece of text + Which is joined to the previous text node with the line breaks +NPC: Bye! +)RAWSUD"; + +const FString GotoParsingInput = R"RAWSUD( +:start +:alsostart +Player: This is the start + :choice + * Go to end + NPC: How rude, bye then + [goto end] + * Nested option + NPC: Some nested text with formatting + * Go to goodbye + Player: Gotta go! + [go to goodbye] + * Skip + [goto secondchoice] + * This is a mistake + NPC: Oh no + [goto this_is_an_error] +:secondchoice +NPC: Yep, this one too + * Go back to choice + NPC: Okay! + [goto choice] + * Return to the start + NPC: Gotcha + [goto start] + * Alternative start, also with no text before + [goto alsostart] +:goodbye +NPC: Bye! +)RAWSUD"; + + +const FString SetVariableParsingInput = R"RAWSUD( +=== +# Set some vars in header +# Text var with an existing localised ID +[set SpeakerName.Player "Protagonist"] @12345@ +# Text var no localised ID +[set ValetName "Bob"] +[set SomeFloat 12.5] +[set SomeName `AName`] +[set EmbeddedQuoteString "Hello this has some \"Embedded Quotes\""] +=== + +Player: Hello +[set SomeInt 99] +NPC: Wotcha +# Test that inserting a set node in between text and choice doesn't break link +[set SomeGender masculine] + * Choice 1 + [set SomeBoolean True] + NPC: Truth + * Choice 2 + NPC: Surprise + [set ValetName "Kate"] + [set SomeGender feminine] +Player: Well + +)RAWSUD"; + + + +const FString ConditionalParsingInput = R"RAWSUD( +=== +# Nothing in header but a comment +=== + +# A comment in body + +Player: Excuse me? +NPC: Well, hello there. This is a test. + * A test? + NPC: Yes, a test. This is some indented continuation text. + Player: Oh I see, thank you. + [if $polite == 1] + NPC: You're welcome. + [else] + NPC: Too right. + [endif] + * Another option + NPC: This is another option with an embedded choice. + * How far can this go? + NPC: Theoretically forever but who knows? + [if $extraq] + * This is an extra question + NPC: That should have been added to the previous choice + * Another question? + NPC: Yep, this one too + [endif] +Player: Well, that's all for now. This should appear for all paths as a fall-through. + This, in fact, is a multi-line piece of text + Which is joined to the previous text node with the line breaks +NPC: Bye! +)RAWSUD"; + + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestSimpleParsing, + "SUDSTest.TestSimpleParsing", + EAutomationTestFlags::EditorContext | + EAutomationTestFlags::ClientContext | + EAutomationTestFlags::ProductFilter) + + +bool FTestSimpleParsing::RunTest(const FString& Parameters) +{ + FSUDSMessageLogger Logger(false); + FSUDSScriptImporter Importer; + TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(SimpleParsingInput), SimpleParsingInput.Len(), "SimpleParsingInput", &Logger, true)); + + // Test the content of the parsing + auto RootNode = Importer.GetNode(0); + if (!TestNotNull("Root node should exist", RootNode)) + return false; + + TestEqual("Root node type", RootNode->NodeType, ESUDSParsedNodeType::Text); + TestEqual("Root node speaker", RootNode->Identifier, "Player"); + TestEqual("Root node text", RootNode->Text, "Excuse me?"); + TestEqual("Root node path", RootNode->ChoicePath, "/"); + TestEqual("Root node edges", RootNode->Edges.Num(), 1); + + auto NextNode = Importer.GetNode(RootNode->Edges[0].TargetNodeIdx); + if (!TestNotNull("Next node should exist", NextNode)) + return false; + + TestEqual("Second node type", NextNode->NodeType, ESUDSParsedNodeType::Text); + TestEqual("Second node speaker", NextNode->Identifier, "NPC"); + TestEqual("Second node text", NextNode->Text, "Well, hello there. This is a test."); + TestEqual("Second node path", RootNode->ChoicePath, "/"); + TestEqual("Second node edges", NextNode->Edges.Num(), 1); + + NextNode = Importer.GetNode(NextNode->Edges[0].TargetNodeIdx); + if (!TestNotNull("Third node should exist", NextNode)) + return false; + TestEqual("Third node type", NextNode->NodeType, ESUDSParsedNodeType::Choice); + // Choice itself is still at root, only the edges (individual choices) introduce new path levels + TestEqual("Third node path", NextNode->ChoicePath, "/"); + TestEqual("Third node edges", NextNode->Edges.Num(), 2); + + auto Choice1Node = NextNode; + const FSUDSParsedNode* FallthroughNode = nullptr; + if (NextNode->Edges.Num() >= 2) + { + TestEqual("Choice 1 node edge 0 text", Choice1Node->Edges[0].Text, "A test?"); + TestEqual("Choice 1 node edge 1 text", Choice1Node->Edges[1].Text, "Another option"); + // Follow choice 1 + NextNode = Importer.GetNode(Choice1Node->Edges[0].TargetNodeIdx); + if (TestNotNull("Next node should exist", NextNode)) + { + TestEqual("Choice 1 1st text node type", NextNode->NodeType, ESUDSParsedNodeType::Text); + TestEqual("Choice 1 1st text node speaker", NextNode->Identifier, "NPC"); + TestEqual("Choice 1 1st text node text", NextNode->Text, "Yes, a test. This is some indented continuation text."); + TestEqual("Choice 1 1st text node path", NextNode->ChoicePath, "/C001/"); + TestEqual("Choice 1 1st text node edges", NextNode->Edges.Num(), 1); + NextNode = Importer.GetNode(NextNode->Edges[0].TargetNodeIdx); + if (TestNotNull("Next node should exist", NextNode)) + { + TestEqual("Choice 1 2nd text node type", NextNode->NodeType, ESUDSParsedNodeType::Text); + TestEqual("Choice 1 2nd text node speaker", NextNode->Identifier, "Player"); + TestEqual("Choice 1 2nd text node text", NextNode->Text, "Oh I see, thank you."); + TestEqual("Choice 1 2nd text node path", NextNode->ChoicePath, "/C001/"); + TestEqual("Choice 1 2nd text node edges", NextNode->Edges.Num(), 1); + NextNode = Importer.GetNode(NextNode->Edges[0].TargetNodeIdx); + if (TestNotNull("Next node should exist", NextNode)) + { + TestEqual("Choice 1 3rd text node type", NextNode->NodeType, ESUDSParsedNodeType::Text); + TestEqual("Choice 1 3rd text node speaker", NextNode->Identifier, "NPC"); + TestEqual("Choice 1 3rd text node text", NextNode->Text, "You're welcome."); + TestEqual("Choice 1 3rd text node path", NextNode->ChoicePath, "/C001/"); + + // Should fall through, all the way to the end and not to "level 2 fallthrough" since that's deeper level + TestEqual("Choice 1 3rd text node edges", NextNode->Edges.Num(), 1); + if (NextNode->Edges.Num() >= 1) + { + auto LinkedNode = Importer.GetNode(NextNode->Edges[0].TargetNodeIdx); + if (TestNotNull("Choice 1 3rd text linked node", LinkedNode)) + { + TestTrue("Choice 1 3rd text target node", LinkedNode->Text.StartsWith("Well, that's all for now")); + FallthroughNode = LinkedNode; + } + } + } + } + } + + + // Follow choice 2 + NextNode = Importer.GetNode(Choice1Node->Edges[1].TargetNodeIdx); + if (!TestNotNull("Next node should exist", NextNode)) + return false; + + TestEqual("Choice 2 1st text node type", NextNode->NodeType, ESUDSParsedNodeType::Text); + TestEqual("Choice 2 1st text node speaker", NextNode->Identifier, "NPC"); + TestEqual("Choice 2 1st text node text", NextNode->Text, "This is another option with an embedded choice."); + TestEqual("Choice 2 2nd text node path", NextNode->ChoicePath, "/C002/"); + TestEqual("Choice 2 1st text node edges", NextNode->Edges.Num(), 1); + NextNode = Importer.GetNode(NextNode->Edges[0].TargetNodeIdx); + if (!TestNotNull("Next node should exist", NextNode)) + return false; + // This is nested choice node + TestEqual("Choice 2 1st text node type", NextNode->NodeType, ESUDSParsedNodeType::Choice); + TestEqual("Choice 2 nested choice edges", NextNode->Edges.Num(), 3); + + auto NestedChoiceNode = NextNode; + if (NestedChoiceNode->Edges.Num() >= 3) + { + TestEqual("Nested choice edge text 0", NestedChoiceNode->Edges[0].Text, "How far can this go?"); + + NextNode = Importer.GetNode(NestedChoiceNode->Edges[0].TargetNodeIdx); + if (TestNotNull("Next node should exist", NextNode)) + { + TestEqual("Nested Choice 1st text node type", NextNode->NodeType, ESUDSParsedNodeType::Text); + TestEqual("Nested Choice 1st text node speaker", NextNode->Identifier, "NPC"); + TestEqual("Nested Choice 1st text node text", NextNode->Text, "Theoretically forever but who knows?"); + // Choice edges are assigned unique numbers in ascending order, but nested + // This helps with fallthrough + TestEqual("Nested Choice 1st text node path", NextNode->ChoicePath, "/C002/C003/"); + + if (TestEqual("Nested Choice 1st text node edges", NextNode->Edges.Num(), 1)) + { + // Should fall through + auto LinkedNode = Importer.GetNode(NextNode->Edges[0].TargetNodeIdx); + if (TestNotNull("Nested Choice 1st linked node", LinkedNode)) + { + TestEqual("Nested Choice 1st text target node", LinkedNode->Text, "This is a level 2 fallthrough"); + } + } + } + + TestEqual("Nested choice edge text 1", NestedChoiceNode->Edges[1].Text, "This is an extra question"); + NextNode = Importer.GetNode(NestedChoiceNode->Edges[1].TargetNodeIdx); + if (TestNotNull("Next node should exist", NextNode)) + { + TestEqual("Nested Choice 2nd text node type", NextNode->NodeType, ESUDSParsedNodeType::Text); + TestEqual("Nested Choice 2nd text node speaker", NextNode->Identifier, "NPC"); + TestEqual("Nested Choice 2nd text node text", NextNode->Text, "That should have been added to the previous choice"); + TestEqual("Nested Choice 2nd text node path", NextNode->ChoicePath, "/C002/C004/"); + TestEqual("Nested Choice 2nd text node edges", NextNode->Edges.Num(), 1); + if (TestEqual("Nested Choice 2nd text node edges", NextNode->Edges.Num(), 1)) + { + // Should fall through + auto LinkedNode = Importer.GetNode(NextNode->Edges[0].TargetNodeIdx); + if (TestNotNull("Nested Choice 2nd linked node", LinkedNode)) + { + TestEqual("Nested Choice 2nd text target node", LinkedNode->Text, "This is a level 2 fallthrough"); + } + } + } + + TestEqual("Nested choice edge text 2", NestedChoiceNode->Edges[2].Text, "Another question?"); + NextNode = Importer.GetNode(NestedChoiceNode->Edges[2].TargetNodeIdx); + if (TestNotNull("Next node should exist", NextNode)) + { + TestEqual("Nested Choice 3rd text node type", NextNode->NodeType, ESUDSParsedNodeType::Text); + TestEqual("Nested Choice 3rd text node speaker", NextNode->Identifier, "NPC"); + TestEqual("Nested Choice 3rd text node text", NextNode->Text, "Yep, this one too"); + TestEqual("Nested Choice 3rd text node path", NextNode->ChoicePath, "/C002/C005/"); + if (TestEqual("Nested Choice 3rd text node edges", NextNode->Edges.Num(), 1)) + { + // Double nested + auto DoubleChoiceNode = Importer.GetNode(NextNode->Edges[0].TargetNodeIdx); + if (TestNotNull("Double Nested Choice node", DoubleChoiceNode)) + { + TestEqual("Double Nested Choice node type", DoubleChoiceNode->NodeType, ESUDSParsedNodeType::Choice); + if (TestEqual("Double Nested Choice node edges", DoubleChoiceNode->Edges.Num(), 3)) + { + + TestEqual("Double Nested choice edge text 0", DoubleChoiceNode->Edges[0].Text, "A third level of questions?"); + NextNode = Importer.GetNode(DoubleChoiceNode->Edges[0].TargetNodeIdx); + if (TestNotNull("Double Nested Choice node option 0", NextNode)) + { + TestEqual("Double Nested Choice option 0 text node type", NextNode->NodeType, ESUDSParsedNodeType::Text); + TestEqual("Double Nested Choice option 0 text node speaker", NextNode->Identifier, "NPC"); + TestEqual("Double Nested Choice option 0 text node text", NextNode->Text, "Yes, really!"); + // Should fall through + if (TestEqual("Double Nested Choice option 0 text edge", NextNode->Edges.Num(), 1)) + NextNode = Importer.GetNode(NextNode->Edges[0].TargetNodeIdx); + if (TestNotNull("Double Nested Choice option 0 text linked node", NextNode)) + { + TestEqual("Double Nested Choice option 0 text linked node", NextNode->Text, "This is a level 2 fallthrough"); + } + } + + TestEqual("Double Nested choice edge text 1", DoubleChoiceNode->Edges[1].Text, "Wow"); + NextNode = Importer.GetNode(DoubleChoiceNode->Edges[1].TargetNodeIdx); + if (TestNotNull("Double Nested Choice node option 1", NextNode)) + { + TestEqual("Double Nested Choice option 1 text node type", NextNode->NodeType, ESUDSParsedNodeType::Text); + TestEqual("Double Nested Choice option 1 text node speaker", NextNode->Identifier, "NPC"); + TestEqual("Double Nested Choice option 1 text node text", NextNode->Text, "IKR"); + // Should fall through + if (TestEqual("Double Nested Choice option 1 text edge", NextNode->Edges.Num(), 1)) + NextNode = Importer.GetNode(NextNode->Edges[0].TargetNodeIdx); + if (TestNotNull("Double Nested Choice option 0 text linked node", NextNode)) + { + TestEqual("Double Nested Choice option 0 text linked node", NextNode->Text, "This is a level 2 fallthrough"); + } + } + + // Last Should fall through + TestEqual("Double Nested choice edge text 2", DoubleChoiceNode->Edges[2].Text, "Continuation with no response, just fallthrough"); + auto LinkedNode = Importer.GetNode(DoubleChoiceNode->Edges[2].TargetNodeIdx); + if (TestNotNull("Nested Choice 3rd linked node", LinkedNode)) + { + TestEqual("Nested Choice 3rd text target node", LinkedNode->Text, "This is a level 2 fallthrough"); + } + } + + } + + } + } + + } + + if (TestNotNull("Should have found fallthrough node", FallthroughNode)) + { + // Test the final fallthrough + TestEqual("Fallthrough node type", FallthroughNode->NodeType, ESUDSParsedNodeType::Text); + TestEqual("Fallthrough node speaker", FallthroughNode->Identifier, "Player"); + TestEqual("Fallthrough node text", FallthroughNode->Text, "Well, that's all for now. This should appear for all paths as a fall-through.\nThis, in fact, is a multi-line piece of text\nWhich is joined to the previous text node with the line breaks"); + if (TestEqual("Fallthrough node edge count", FallthroughNode->Edges.Num(), 1)) + { + NextNode = Importer.GetNode(FallthroughNode->Edges[0].TargetNodeIdx); + if (TestNotNull("Fallthrough node next node not null", NextNode)) + { + TestEqual("Fallthrough node 2 type", NextNode->NodeType, ESUDSParsedNodeType::Text); + TestEqual("Fallthrough node 2 speaker", NextNode->Identifier, "NPC"); + TestEqual("Fallthrough node 2 text", NextNode->Text, "Bye!"); + // Should have no further edges since is at end + TestEqual("Fallthrough node 2 edge count", NextNode->Edges.Num(), 0); + } + } + + + } + + } + + return true; +} + + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestGotoParsing, + "SUDSTest.TestGotoParsing", + EAutomationTestFlags::EditorContext | + EAutomationTestFlags::ClientContext | + EAutomationTestFlags::ProductFilter) + + +bool FTestGotoParsing::RunTest(const FString& Parameters) +{ + FSUDSMessageLogger Logger(false); + FSUDSScriptImporter Importer; + TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(GotoParsingInput), GotoParsingInput.Len(), "GotoParsingInput", &Logger, true)); + + // Test the content of the parsing + auto RootNode = Importer.GetNode(0); + if (!TestNotNull("Root node should exist", RootNode)) + return false; + + TestEqual("Root node type", RootNode->NodeType, ESUDSParsedNodeType::Text); + TestEqual("Root node speaker", RootNode->Identifier, "Player"); + TestEqual("Root node text", RootNode->Text, "This is the start"); + TestEqual("Root node edges", RootNode->Edges.Num(), 1); + + auto NextNode = Importer.GetNode(RootNode->Edges[0].TargetNodeIdx); + if (!TestNotNull("Next node should exist", NextNode)) + return false; + + TestEqual("Choice node type", NextNode->NodeType, ESUDSParsedNodeType::Choice); + if (TestEqual("Choice node edges", NextNode->Edges.Num(), 2)) + { + auto ChoiceNode = NextNode; + TestEqual("Choice 1 text", ChoiceNode->Edges[0].Text, "Go to end"); + NextNode = Importer.GetNode(ChoiceNode->Edges[0].TargetNodeIdx); + if (TestNotNull("Next node should not be null", NextNode)) + { + TestEqual("Goto End node text type", NextNode->NodeType, ESUDSParsedNodeType::Text); + TestEqual("Goto End node text speaker", NextNode->Identifier, "NPC"); + TestEqual("Goto End node text text", NextNode->Text, "How rude, bye then"); + if (TestEqual("Goto End node text edges", NextNode->Edges.Num(), 1)) + { + NextNode = Importer.GetNode(NextNode->Edges[0].TargetNodeIdx); + if (TestNotNull("Goto node should not be null", NextNode)) + { + TestEqual("Goto End node type", NextNode->NodeType, ESUDSParsedNodeType::Goto); + TestEqual("Goto End node label", NextNode->Identifier, FSUDSScriptImporter::EndGotoLabel); + } + + } + + } + TestEqual("Choice 2 text", ChoiceNode->Edges[1].Text, "Nested option"); + NextNode = Importer.GetNode(ChoiceNode->Edges[1].TargetNodeIdx); + if (TestNotNull("Next node should not be null", NextNode)) + { + TestEqual("Choice 2 node text type", NextNode->NodeType, ESUDSParsedNodeType::Text); + TestEqual("Choice 2 node text speaker", NextNode->Identifier, "NPC"); + TestEqual("Choice 2 node text text", NextNode->Text, "Some nested text with formatting"); + if (TestEqual("Choice 2 node text edges", NextNode->Edges.Num(), 1)) + { + NextNode = Importer.GetNode(NextNode->Edges[0].TargetNodeIdx); + if (TestNotNull("Nested choice node should not be null", NextNode)) + { + TestEqual("Goto End node type", NextNode->NodeType, ESUDSParsedNodeType::Choice); + auto NestedChoice = NextNode; + if (TestEqual("Nested Choice node edges", NextNode->Edges.Num(), 3)) + { + TestEqual("Nested choice 0 text", NestedChoice->Edges[0].Text, "Go to goodbye"); + NextNode = Importer.GetNode(NestedChoice->Edges[0].TargetNodeIdx); + if (TestNotNull("Should not be null", NextNode)) + { + TestEqual("Nested choice 0 text type", NextNode->NodeType, ESUDSParsedNodeType::Text); + TestEqual("Nested choice 0 text speaker", NextNode->Identifier, "Player"); + TestEqual("Nested choice 0 text text", NextNode->Text, "Gotta go!"); + + if (TestEqual("Nested Choice textnode edges", NextNode->Edges.Num(), 1)) + { + NextNode = Importer.GetNode(NextNode->Edges[0].TargetNodeIdx); + if (TestNotNull("Should not be null", NextNode)) + { + // This is the goto + TestEqual("Nested choice 0 goto type", NextNode->NodeType, ESUDSParsedNodeType::Goto); + TestEqual("Nested choice 0 goto label", NextNode->Identifier, "goodbye"); + const int DestIdx = Importer.GetGotoTargetNodeIndex(NextNode->Identifier); + TestNotEqual("Label should be valid", DestIdx, -1); + NextNode = Importer.GetNode(DestIdx); + if (TestNotNull("Goto dest node", NextNode)) + { + TestEqual("Goto dest text type", NextNode->NodeType, ESUDSParsedNodeType::Text); + TestEqual("Goto dest text speaker", NextNode->Identifier, "NPC"); + TestEqual("Goto dest text text", NextNode->Text, "Bye!"); + } + } + } + } + TestEqual("Nested choice 1 text", NestedChoice->Edges[1].Text, "Skip"); + NextNode = Importer.GetNode(NestedChoice->Edges[1].TargetNodeIdx); + if (TestNotNull("Should not be null", NextNode)) + { + // This one goes straight to goto + TestEqual("Nested choice 1 goto type", NextNode->NodeType, ESUDSParsedNodeType::Goto); + TestEqual("Nested choice 1 goto label", NextNode->Identifier, "secondchoice"); + + const int DestIdx = Importer.GetGotoTargetNodeIndex(NextNode->Identifier); + TestNotEqual("Label should be valid", DestIdx, -1); + NextNode = Importer.GetNode(DestIdx); + if (TestNotNull("Goto dest node", NextNode)) + { + TestEqual("Goto dest text type", NextNode->NodeType, ESUDSParsedNodeType::Text); + TestEqual("Goto dest text speaker", NextNode->Identifier, "NPC"); + TestEqual("Goto dest text text", NextNode->Text, "Yep, this one too"); + + } + } + TestEqual("Nested choice 2 text", NestedChoice->Edges[2].Text, "This is a mistake"); + NextNode = Importer.GetNode(NestedChoice->Edges[2].TargetNodeIdx); + if (TestNotNull("Should not be null", NextNode)) + { + TestEqual("Nested choice 2 text type", NextNode->NodeType, ESUDSParsedNodeType::Text); + TestEqual("Nested choice 2 text speaker", NextNode->Identifier, "NPC"); + TestEqual("Nested choice 2 text text", NextNode->Text, "Oh no"); + + if (TestEqual("Nested Choice text node edges", NextNode->Edges.Num(), 1)) + { + NextNode = Importer.GetNode(NextNode->Edges[0].TargetNodeIdx); + if (TestNotNull("Should not be null", NextNode)) + { + // This is the goto + TestEqual("Nested choice 0 goto type", NextNode->NodeType, ESUDSParsedNodeType::Goto); + TestEqual("Nested choice 0 goto label", NextNode->Identifier, "this_is_an_error"); + TestEqual("Label should go nowhere", Importer.GetGotoTargetNodeIndex(NextNode->Identifier), -1); + } + } + + } + + } + } + + } + + } + + // Pick up the latter part (only reachable by goto) + NextNode = Importer.GetNode(Importer.GetGotoTargetNodeIndex("secondchoice")); + if (TestNotNull("End choice node should not be null", NextNode)) + { + TestEqual("End choice node text type", NextNode->NodeType, ESUDSParsedNodeType::Text); + TestEqual("End choice node text speaker", NextNode->Identifier, "NPC"); + TestEqual("End choice node text text", NextNode->Text, "Yep, this one too"); + if (TestEqual("End choice node text edges", NextNode->Edges.Num(), 1)) + { + NextNode = Importer.GetNode(NextNode->Edges[0].TargetNodeIdx); + if (TestNotNull("", NextNode)) + { + TestEqual("End choice node text type", NextNode->NodeType, ESUDSParsedNodeType::Choice); + + if (TestEqual("End choice node edges", NextNode->Edges.Num(), 3)) + { + auto EndChoiceNode = NextNode; + TestEqual("End choice 0 text", EndChoiceNode->Edges[0].Text, "Go back to choice"); + NextNode = Importer.GetNode(EndChoiceNode->Edges[0].TargetNodeIdx); + if (TestNotNull("", NextNode)) + { + TestEqual("Node text type", NextNode->NodeType, ESUDSParsedNodeType::Text); + TestEqual("Node text speaker", NextNode->Identifier, "NPC"); + TestEqual("Node text text", NextNode->Text, "Okay!"); + if (TestEqual("Next node edges", NextNode->Edges.Num(), 1)) + { + NextNode = Importer.GetNode(NextNode->Edges[0].TargetNodeIdx); + if (TestNotNull("", NextNode)) + { + TestEqual("Should be goto node", NextNode->NodeType, ESUDSParsedNodeType::Goto); + TestEqual("Goto label", NextNode->Identifier, "choice"); + + // This should lead back to a choice node, ie letting the previous text node use the same choices + // without repeating the text (loop with context) + const int DestIdx = Importer.GetGotoTargetNodeIndex(NextNode->Identifier); + TestNotEqual("Label should be valid", DestIdx, -1); + NextNode = Importer.GetNode(DestIdx); + if (TestNotNull("Goto dest node", NextNode)) + { + TestEqual("Goto dest choice type", NextNode->NodeType, ESUDSParsedNodeType::Choice); + // Just make sure it's the right choice node + if (TestEqual("Goto dest choice edges", NextNode->Edges.Num(), 2)) + { + TestEqual("Goto dest choice check edge", NextNode->Edges[0].Text, "Go to end"); + } + } + + } + } + + NextNode = Importer.GetNode(EndChoiceNode->Edges[1].TargetNodeIdx); + if (TestNotNull("", NextNode)) + { + TestEqual("Node text type", NextNode->NodeType, ESUDSParsedNodeType::Text); + TestEqual("Node text speaker", NextNode->Identifier, "NPC"); + TestEqual("Node text text", NextNode->Text, "Gotcha"); + if (TestEqual("Next node edges", NextNode->Edges.Num(), 1)) + { + NextNode = Importer.GetNode(NextNode->Edges[0].TargetNodeIdx); + if (TestNotNull("", NextNode)) + { + TestEqual("Should be goto node", NextNode->NodeType, ESUDSParsedNodeType::Goto); + TestEqual("Goto label", NextNode->Identifier, "start"); + + // This should lead back to a choice node, ie letting the previous text node use the same choices + // without repeating the text (loop with context) + const int DestIdx = Importer.GetGotoTargetNodeIndex(NextNode->Identifier); + TestNotEqual("Label should be valid", DestIdx, -1); + NextNode = Importer.GetNode(DestIdx); + if (TestNotNull("Goto dest node", NextNode)) + { + // Just make sure it's the right node + TestEqual("Goto dest text type", NextNode->NodeType, ESUDSParsedNodeType::Text); + TestEqual("Node text speaker", NextNode->Identifier, "Player"); + TestEqual("Node text text", NextNode->Text, "This is the start"); + } + + } + } + } + NextNode = Importer.GetNode(EndChoiceNode->Edges[2].TargetNodeIdx); + if (TestNotNull("", NextNode)) + { + // Straight to goto + TestEqual("Should be goto node", NextNode->NodeType, ESUDSParsedNodeType::Goto); + TestEqual("Goto label", NextNode->Identifier, "alsostart"); + + // This should lead back to a choice node, ie letting the previous text node use the same choices + // without repeating the text (loop with context) + const int DestIdx = Importer.GetGotoTargetNodeIndex(NextNode->Identifier); + TestNotEqual("Label should be valid", DestIdx, -1); + NextNode = Importer.GetNode(DestIdx); + if (TestNotNull("Goto dest node", NextNode)) + { + // Just make sure it's the right node + TestEqual("Goto dest text type", NextNode->NodeType, ESUDSParsedNodeType::Text); + TestEqual("Node text speaker", NextNode->Identifier, "Player"); + TestEqual("Node text text", NextNode->Text, "This is the start"); + } + } + } + } + } + } + + + + } + + // Test that start & autostart point to the same place + const int StartNodeIdx = Importer.GetGotoTargetNodeIndex("start"); + const auto StartNode = Importer.GetNode(StartNodeIdx); + if (TestNotNull("Second choice node should not be null", StartNode)) + { + TestEqual("Start node text type", StartNode->NodeType, ESUDSParsedNodeType::Text); + TestEqual("Start node text speaker", StartNode->Identifier, "Player"); + TestEqual("Start node text text", StartNode->Text, "This is the start"); + } + const int AlsoStartNodeIdx = Importer.GetGotoTargetNodeIndex("alsostart"); + TestEqual("start and alsostart should reference the same node", AlsoStartNodeIdx, StartNodeIdx); + + } + + return true; +} + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestSetVariableParsing, + "SUDSTest.TestSetVariableParsing", + EAutomationTestFlags::EditorContext | + EAutomationTestFlags::ClientContext | + EAutomationTestFlags::ProductFilter) + + +bool FTestSetVariableParsing::RunTest(const FString& Parameters) +{ + FSUDSMessageLogger Logger(false); + FSUDSScriptImporter Importer; + TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(SetVariableParsingInput), SetVariableParsingInput.Len(), "SetVariableParsingInput", &Logger, true)); + // Test the content of the parsing + + // Header nodes first + auto NextNode = Importer.GetHeaderNode(0); + TestParsedSetLiteral(this, "Header node 1", NextNode, "SpeakerName.Player", "Protagonist"); + TestGetParsedNextNode(this, "Header node 1 next", NextNode, Importer, true, &NextNode); + TestParsedSetLiteral(this, "Header node 2", NextNode, "ValetName", "Bob"); + TestGetParsedNextNode(this, "Header node 2 next", NextNode, Importer, true, &NextNode); + TestParsedSetLiteral(this, "Header node 3", NextNode, "SomeFloat", 12.5f); + TestGetParsedNextNode(this, "Header node 3 next", NextNode, Importer, true, &NextNode); + TestParsedSetLiteral(this, "Header node 4", NextNode, "SomeName", FName("AName")); + TestGetParsedNextNode(this, "Header node 4 next", NextNode, Importer, true, &NextNode); + TestParsedSetLiteral(this, "Header node 5", NextNode, "EmbeddedQuoteString", "Hello this has some \"Embedded Quotes\""); + + // Now body nodes + NextNode = Importer.GetNode(0); + TestParsedText(this, "Root node", NextNode, "Player", "Hello"); + TestGetParsedNextNode(this, "Node 1 next", NextNode, Importer, false, &NextNode); + TestParsedSetLiteral(this, "Node 2", NextNode, "SomeInt", 99); + TestGetParsedNextNode(this, "Node 2 next", NextNode, Importer, false, &NextNode); + TestParsedText(this, "Node 3 text", NextNode, "NPC", "Wotcha"); + TestGetParsedNextNode(this, "Node 3 next", NextNode, Importer, false, &NextNode); + TestParsedSetLiteral(this, "Node 4", NextNode, "SomeGender", ETextGender::Masculine); + TestGetParsedNextNode(this, "Node 4 next", NextNode, Importer, false, &NextNode); + TestParsedChoice(this, "Node 5 choice", NextNode, 2); + auto ChoiceNode = NextNode; + TestParsedChoiceEdge(this, "Choice 1 edge", ChoiceNode, 0, "Choice 1", Importer, &NextNode); + TestParsedSetLiteral(this, "Choice 1 set", NextNode, "SomeBoolean", true); + TestGetParsedNextNode(this, "Choice 1 next", NextNode, Importer, false, &NextNode); + TestParsedText(this, "Choice 1 text", NextNode, "NPC", "Truth"); + + TestParsedChoiceEdge(this, "Choice 2 edge", ChoiceNode, 1, "Choice 2", Importer, &NextNode); + TestParsedText(this, "Choice 2 text", NextNode, "NPC", "Surprise"); + TestGetParsedNextNode(this, "Choice 2 next", NextNode, Importer, false, &NextNode); + TestParsedSetLiteral(this, "Choice 2 set", NextNode, "ValetName", "Kate"); + TestGetParsedNextNode(this, "Choice 2 next 2", NextNode, Importer, false, &NextNode); + TestParsedSetLiteral(this, "Choice 2 set 2", NextNode, "SomeGender", ETextGender::Feminine); + + return true; +} + + + + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestConversionToRuntime, + "SUDSTest.TestConversionToRuntime", + EAutomationTestFlags::EditorContext | + EAutomationTestFlags::ClientContext | + EAutomationTestFlags::ProductFilter) + + +bool FTestConversionToRuntime::RunTest(const FString& Parameters) +{ + FSUDSMessageLogger Logger(false); + FSUDSScriptImporter Importer; + TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(GotoParsingInput), GotoParsingInput.Len(), "GotoParsingInput", &Logger, true)); + + auto Asset = NewObject(GetTransientPackage(), "Test"); + const ScopedStringTableHolder StringTableHolder; + Importer.PopulateAsset(Asset, StringTableHolder.StringTable); + + auto StartNode = Asset->GetFirstNode(); + if (!TestNotNull("Start node should be true", StartNode)) + { + return false; + } + + TestTextNode(this, "Start node", StartNode, "Player", "This is the start"); + + auto NextNode = StartNode; + if (!TestEqual("Start node edges", NextNode->GetEdgeCount(), 1)) + { + return false; + } + + auto pEdge = NextNode->GetEdge(0); + if (!TestNotNull("Start node edge", pEdge)) + { + return false; + } + + NextNode = pEdge->GetTargetNode().Get(); + if (TestChoiceNode(this, "Start node next", NextNode, 2)) + { + auto ChoiceNode = NextNode; + if (TestChoiceEdge(this, "Choice 1 text", ChoiceNode, 0, "Go to end", &NextNode)) + { + TestTextNode(this, "Goto End node text type", NextNode, "NPC", "How rude, bye then"); + // This is a goto end so should just have one edge + auto GotoEdge = NextNode->GetEdge(0); + if (TestNotNull("Goto end node", GotoEdge)) + { + TestFalse("Goto end should be null", GotoEdge->GetTargetNode().IsValid()); + } + } + + if (TestChoiceEdge(this, "Choice 2 text", ChoiceNode, 1, "Nested option", &NextNode)) + { + TestTextNode(this, "Choice 2 node text type", NextNode, "NPC", "Some nested text with formatting"); + if (TestEdge(this, "Choice 2 node text edges", NextNode, 0, &NextNode)) + { + if (TestChoiceNode(this, "Goto End node type", NextNode, 3)) + { + auto NestedChoice = NextNode; + if (TestChoiceEdge(this, "Nested choice 0 text", NestedChoice, 0, "Go to goodbye", &NextNode)) + { + TestTextNode(this, "Nested choice 0 text type", NextNode, "Player", "Gotta go!"); + if (TestEdge(this, "Nested Choice textnode edges", NextNode, 0, &NextNode)) + { + // This was the goto which should have led to goodbye + TestTextNode(this, "Check goto goodbye", NextNode, "NPC", "Bye!"); + } + } + + if (TestChoiceEdge(this, "Nested choice 1 text", NestedChoice, 1, "Skip", &NextNode)) + { + // This will go directly to secondchoice node + TestTextNode(this, "Nested choice 1 goto", NextNode, "NPC", "Yep, this one too"); + } + + if (TestChoiceEdge(this, "Nested choice 2 text", NestedChoice, 2, "This is a mistake", &NextNode)) + { + // there's a text node then a failed goto (nowhere) + TestTextNode(this, "Nested choice 2 node", NextNode, "NPC", "Oh no"); + auto GotoEdge = NextNode->GetEdge(0); + if (TestNotNull("Nested choice 2 node edge", GotoEdge)) + { + TestFalse("Nested choice 2 node goes nowhere", GotoEdge->GetTargetNode().IsValid()); + } + + } + } + } + } + + // Pick up the latter part (only reachable by goto) + NextNode = Asset->GetNodeByLabel("secondchoice"); + if (TestTextNode(this, "secondchoice node", NextNode, "NPC", "Yep, this one too")) + { + if (TestEdge(this, "secondchoice node text edges", NextNode, 0, &NextNode)) + { + if (TestChoiceNode(this, "secondchoice next choice", NextNode, 3)) + { + auto EndChoiceNode = NextNode; + if (TestChoiceEdge(this, "End choice 0 edge", EndChoiceNode, 0, "Go back to choice", &NextNode)) + { + TestTextNode(this, "Node text", NextNode, "NPC", "Okay!"); + if (TestEdge(this, "Next node edges", NextNode, 0, &NextNode)) + { + // Should go back to :choice node + TestChoiceNode(this, "Check choice goto", NextNode, 2); + TestChoiceEdge(this, "Check choice goto", NextNode, 0, "Go to end", &NextNode); + } + } + if (TestChoiceEdge(this, "End choice 1 edge", EndChoiceNode, 1, "Return to the start", &NextNode)) + { + TestTextNode(this, "Node text", NextNode, "NPC", "Gotcha"); + if (TestEdge(this, "Next node edges", NextNode, 0, &NextNode)) + { + // Should go back to :start + TestTextNode(this, "Check goto text", NextNode, "Player", "This is the start"); + } + } + if (TestChoiceEdge(this, "End choice 2 edge", EndChoiceNode, 2, "Alternative start, also with no text before", &NextNode)) + { + // Should go directly back to :alsostart, same as :start + TestTextNode(this, "Check goto text", NextNode, "Player", "This is the start"); + } + } + } + } + } + + + + + // check goto labels + auto GotoNode = Asset->GetNodeByLabel("start"); + TestTextNode(this, "Goto Start", GotoNode, "Player", "This is the start"); + GotoNode = Asset->GetNodeByLabel("alsostart"); + TestTextNode(this, "Goto alsostart", GotoNode, "Player", "This is the start"); + GotoNode = Asset->GetNodeByLabel("choice"); + TestChoiceNode(this, "Goto choice", GotoNode, 2); + GotoNode = Asset->GetNodeByLabel("secondchoice"); + TestTextNode(this, "Goto secondchoice", GotoNode, "NPC", "Yep, this one too"); + GotoNode = Asset->GetNodeByLabel("goodbye"); + TestTextNode(this, "Goto goodbye", GotoNode, "NPC", "Bye!"); + + // Test speakers + TestEqual("Num speakers", Asset->GetSpeakers().Num(), 2); + TestTrue("Speaker 1", Asset->GetSpeakers().Contains("Player")); + TestTrue("Speaker 2", Asset->GetSpeakers().Contains("NPC")); + + return true; +} + +const FString PartiallyLocalisedInput = R"RAWSUD( +Vagabond: Well met, fellow! + * Er, hi? + Vagabond: Verily, 'tis wondrous to see such a fine fellow on the road this morn! + [goto FriendlyChat] + * Jog on, mate @0001@ + Vagabond: Well, really! Good day then sir! @0002@ + [goto end] + +:FriendlyChat +Vagabond: Mayhaps we could travel together a while, and share a tale or two? @0007@ +What do you say? +)RAWSUD"; + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestPartiallyLocalised, + "SUDSTest.TestPartiallyLocalised", + EAutomationTestFlags::EditorContext | + EAutomationTestFlags::ClientContext | + EAutomationTestFlags::ProductFilter) + + +bool FTestPartiallyLocalised::RunTest(const FString& Parameters) +{ + FSUDSMessageLogger Logger(false); + FSUDSScriptImporter Importer; + TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(PartiallyLocalisedInput), PartiallyLocalisedInput.Len(), "PartiallyLocalisedInput", &Logger, true)); + + // Test the content of the parsing + auto NextNode = Importer.GetNode(0); + + TestParsedText(this, "Start node", NextNode, "Vagabond", "Well met, fellow!"); + // Test that we generated unique textIDs for inserted lines that are unique + // Should be after the last explicit one + TestFalse("TextID should be populated", NextNode->TextID.IsEmpty()); + TestEqual("TextID should be correct", NextNode->TextID, "@0008@"); + TestGetParsedNextNode(this, "Next", NextNode, Importer, false, &NextNode); + if (TestParsedChoice(this, "First choice", NextNode, 2)) + { + TestFalse("TextID should be populated", NextNode->Edges[0].TextID.IsEmpty()); + TestEqual("TextID should be correct", NextNode->Edges[0].TextID, "@0009@"); + TestFalse("TextID should be populated", NextNode->Edges[1].TextID.IsEmpty()); + TestEqual("TextID should be correct", NextNode->Edges[1].TextID, "@0001@"); + + TestParsedChoiceEdge(this, "First choice", NextNode, 0, "Er, hi?", Importer, &NextNode); + TestParsedText(this, "Next node", NextNode, "Vagabond", "Verily, 'tis wondrous to see such a fine fellow on the road this morn!"); + TestFalse("TextID should be populated", NextNode->TextID.IsEmpty()); + TestEqual("TextID should be correct", NextNode->TextID, "@000a@"); + + TestGetParsedNextNode(this, "Next", NextNode, Importer, false, &NextNode); + TestParsedGoto(this, "Goto", NextNode, Importer, &NextNode); + TestParsedText(this, "Next node", NextNode, "Vagabond", "Mayhaps we could travel together a while, and share a tale or two?\nWhat do you say?"); + TestEqual("TextID should be correct", NextNode->TextID, "@0007@"); + } + + NextNode = Importer.GetNode(0); + TestGetParsedNextNode(this, "Next", NextNode, Importer, false, &NextNode); + if (TestParsedChoice(this, "First choice", NextNode, 2)) + { + TestEqual("TextID should be correct", NextNode->Edges[1].TextID, "@0001@"); + TestParsedChoiceEdge(this, "First choice", NextNode, 1, "Jog on, mate", Importer, &NextNode); + TestParsedText(this, "Next node", NextNode, "Vagabond", "Well, really! Good day then sir!"); + TestEqual("TextID should be correct", NextNode->TextID, "@0002@"); + } + + return true; +} + +const FString ProblemChoiceInput = R"RAWSUD( + +NPC: Well, hello there. This is a test. +:choice + * A test? + Player: Yes! + * This is a mistake; goto goes direct to another choice + [goto choice] +NPC: Bye! +)RAWSUD"; + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestParseChoiceProblem, + "SUDSTest.TestParseChoiceProblem", + EAutomationTestFlags::EditorContext | + EAutomationTestFlags::ClientContext | + EAutomationTestFlags::ProductFilter) + + +bool FTestParseChoiceProblem::RunTest(const FString& Parameters) +{ + FSUDSMessageLogger Logger(false); + FSUDSScriptImporter Importer; + TestFalse("Import should fail", Importer.ImportFromBuffer(GetData(ProblemChoiceInput), ProblemChoiceInput.Len(), "ProblemChoiceInput", &Logger, true)); + + if (TestTrue("Logger should have registered errors", Logger.HasErrors())) + { + FText ErrMsg = Logger.GetErrorMessages()[0]->ToText(); + TestTrue("Error should contain 'Choices MUST show another speaker line'", ErrMsg.ToString().Contains("Choices MUST show another speaker line")); + } + return true; +} + +const FString TrailingEventInput = R"RAWSUD( + +NPC: Well, hello there. This is a test. +* Choice + [event SomeEvent 1] +)RAWSUD"; + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestTrailingEventProblem, + "SUDSTest.TestTrailingEventProblem", + EAutomationTestFlags::EditorContext | + EAutomationTestFlags::ClientContext | + EAutomationTestFlags::ProductFilter) + + +bool FTestTrailingEventProblem::RunTest(const FString& Parameters) +{ + FSUDSMessageLogger Logger(false); + FSUDSScriptImporter Importer; + TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(TrailingEventInput), TrailingEventInput.Len(), "TrailingEventInput", &Logger, true)); + + return true; +} + +const FString ConditionalChoicesNestedAndSiblingsInput = R"RAWSUD( + +Player: Hello + +[if {TopCondition} == 0] + * Top level choice 1 + [goto outro] +[endif] + +[if {TopCondition} == 1] + * Top level choice 2 + [goto outro] + [if {NestedCondition1} == 1] + * Nested condition choice 1 + [goto outro] + [endif] + [if {NestedCondition2} == 0] + * Nested condition choice 2 + [goto outro] + [endif] + [if {NestedCondition3} == 1] + * Nested condition choice 3 + [goto outro] + [endif] +[endif] + +:outro +Player: Bye +)RAWSUD"; + + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestConditionalChoicesNestedAndSiblings, + "SUDSTest.TestConditionalChoicesNestedAndSiblings", + EAutomationTestFlags::EditorContext | + EAutomationTestFlags::ClientContext | + EAutomationTestFlags::ProductFilter) + + +bool FTestConditionalChoicesNestedAndSiblings::RunTest(const FString& Parameters) +{ + FSUDSMessageLogger Logger(false); + FSUDSScriptImporter Importer; + TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(ConditionalChoicesNestedAndSiblingsInput), ConditionalChoicesNestedAndSiblingsInput.Len(), "ConditionalChoicesNestedAndSiblingsInput", &Logger, true)); + + + auto Asset = NewObject(GetTransientPackage(), "Test"); + const ScopedStringTableHolder StringTableHolder; + Importer.PopulateAsset(Asset, StringTableHolder.StringTable); + + auto StartNode = Asset->GetFirstNode(); + if (!TestNotNull("Start node should be true", StartNode)) + { + return false; + } + + TestTextNode(this, "Start node", StartNode, "Player", "Hello"); + + auto NextNode = StartNode; + if (!TestEqual("Start node edges", NextNode->GetEdgeCount(), 1)) + { + return false; + } + + auto pEdge = NextNode->GetEdge(0); + if (!TestNotNull("Start node edge", pEdge)) + { + return false; + } + + /* Node structure: + T -> C1 + C1 -> S1 -> (if TopCondition == 0) C2 --> Top level choice 1 + C1 -> S2 -> (if TopCondition == 1) C3 -> Top Level choice 2 + |-> S3 -> (if NestedCondition1 == 1) -> C4 -> Nested condition choice 1 + |-> S4 -> (if NestedCondition2 == 0) -> C5 -> Nested condition choice 2 + |-> S5 -> (if NestedCondition3 == 0) -> C5 -> Nested condition choice 3 + */ + + NextNode = pEdge->GetTargetNode().Get(); + if (TestChoiceNode(this, "Start node next", NextNode, 2)) + { + auto ChoiceNode = NextNode; + // Top condition == 0 subtree + if (TestChoiceEdge(this, "Select Path 1", ChoiceNode, 0, "", &NextNode)) + { + if (TestSelectNode(this, "Select 1", NextNode, 1)) + { + if (TestSelectEdge(this, "Select 1 Edge", NextNode, 0, "{TopCondition} == 0", &NextNode)) + { + if (TestChoiceNode(this, "Choice 1 Leaf", NextNode, 1)) + { + TestChoiceEdge(this, "Choice 1 Leaf Edge", NextNode, 0, "Top level choice 1", &NextNode); + } + } + } + } + + // Top condition == 1 subtree + if (TestChoiceEdge(this, "Select Path 2", ChoiceNode, 1, "", &NextNode)) + { + if (TestSelectNode(this, "Select 2", NextNode, 1)) + { + if (TestSelectEdge(this, "Select 2 Edge", NextNode, 0, "{TopCondition} == 1", &NextNode)) + { + // This choice node holds the non-nested condition choice, plus 3 more nested selects + // Because each condition is a sibling they become separate select subtrees + if (TestChoiceNode(this, "Choice 2 Mid", NextNode, 4)) + { + auto ChoiceNode2 = NextNode; + TestChoiceEdge(this, "Choice 2 Leaf Edge 1", ChoiceNode2, 0, "Top level choice 2", &NextNode); + + if (TestChoiceEdge(this, "Choice 2 To Select Edge 1", ChoiceNode2, 1, "", &NextNode)) + { + if (TestSelectNode(this, "Select 2", NextNode, 1)) + { + if (TestSelectEdge(this, "Select 2 Edge", NextNode, 0, "{NestedCondition1} == 1", &NextNode)) + { + if (TestChoiceNode(this, "Choice 2 Leaf", NextNode, 1)) + { + TestChoiceEdge(this, "Choice 2 Leaf Edge", NextNode, 0, "Nested condition choice 1", &NextNode); + } + } + } + } + + if (TestChoiceEdge(this, "Choice 2 To Select Edge 2", ChoiceNode2, 2, "", &NextNode)) + { + if (TestSelectNode(this, "Select 3", NextNode, 1)) + { + if (TestSelectEdge(this, "Select 3 Edge", NextNode, 0, "{NestedCondition2} == 0", &NextNode)) + { + if (TestChoiceNode(this, "Choice 3 Leaf", NextNode, 1)) + { + TestChoiceEdge(this, "Choice 3 Leaf Edge", NextNode, 0, "Nested condition choice 2", &NextNode); + } + } + } + } + + if (TestChoiceEdge(this, "Choice 2 To Select Edge 3", ChoiceNode2, 3, "", &NextNode)) + { + if (TestSelectNode(this, "Select 4", NextNode, 1)) + { + if (TestSelectEdge(this, "Select 4 Edge", NextNode, 0, "{NestedCondition3} == 1", &NextNode)) + { + if (TestChoiceNode(this, "Choice 4 Leaf", NextNode, 1)) + { + TestChoiceEdge(this, "Choice 4 Leaf Edge", NextNode, 0, "Nested condition choice 3", &NextNode); + } + } + } + } + + } + } + } + } + + + } + return true; +} + +// The deliberate mistake here is that the speaker lines are not indented underneath the choices, +// which when combined with the conditionals confuses the parser so that it leaves all but the +// first choice orphaned and unreachable. Import should report this to the user +const FString ErrorsReportedForBadChoiceIndentsInput = R"RAWSUD( + +npc: hello +:chatchoices + [if {npc_hasQuests}] + * Any news? + player: Any news? + [endif] + [if {npc_canTrade}] + * Trade + player: What do you have for sale? + [endif] + [if {npc_hasLore}] + * How are you? + player: How are things? + [endif] + * Nevermind + npc: Alright. Come back anytime. +)RAWSUD"; + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestErrorsReportedForBadChoiceIndents, + "SUDSTest.TestErrorsReportedForBadChoiceIndents", + EAutomationTestFlags::EditorContext | + EAutomationTestFlags::ClientContext | + EAutomationTestFlags::ProductFilter) + + +bool FTestErrorsReportedForBadChoiceIndents::RunTest(const FString& Parameters) +{ + FSUDSMessageLogger Logger(false); + FSUDSScriptImporter Importer; + TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(ErrorsReportedForBadChoiceIndentsInput), ErrorsReportedForBadChoiceIndentsInput.Len(), "ErrorsReportedForBadChoiceIndentsInput", &Logger, true)); + + TestTrue("Warnings should have been logged", Logger.HasWarnings()); + return true; +} + + + +UE_ENABLE_OPTIMIZATION \ No newline at end of file diff --git a/Plugins/SUDS/Source/SUDSTest/Private/TestParticipant.cpp b/Plugins/SUDS/Source/SUDSTest/Private/TestParticipant.cpp new file mode 100644 index 00000000..9daee109 --- /dev/null +++ b/Plugins/SUDS/Source/SUDSTest/Private/TestParticipant.cpp @@ -0,0 +1,68 @@ + +#include "TestParticipant.h" + +#include "SUDSDialogue.h" + +void UTestParticipant::OnDialogueStarting_Implementation(USUDSDialogue* Dialogue, FName AtLabel) +{ + switch(TestNumber) + { + default: + case 0: + Dialogue->SetVariable("SpeakerName.Player", FText::FromString("Protagonist")); + Dialogue->SetVariable("SpeakerName.NPC", FText::FromString("An NPC")); + Dialogue->SetVariable("NumCats", 3); + Dialogue->SetVariableText("FriendName", FText::FromString("Susan")); + Dialogue->SetVariable("Gender", ETextGender::Feminine); + Dialogue->SetVariableFloat("FloatVal", 12.567); + Dialogue->SetVariableBoolean("BoolVal", true); + break; + case 1: + Dialogue->SetVariable("SpeakerName.Player", FText::FromString("Hero")); + Dialogue->SetVariable("SpeakerName.NPC", FText::FromString("Bob The NPC")); + Dialogue->SetVariableText("FriendName", FText::FromString("Derek")); + Dialogue->SetVariable("Gender", ETextGender::Masculine); + Dialogue->SetVariable("NumCats", 5); + break; + case 2: + // these will all be overridden by higher priorities + Dialogue->SetVariable("SpeakerName.Player", FText::FromString("Dweeb")); + Dialogue->SetVariable("SpeakerName.NPC", FText::FromString("Imaginary Friend")); + Dialogue->SetVariable("NumCats", 10); + Dialogue->SetVariableFloat("FloatVal", 0.002); + // This one will be unique and so will still get through + Dialogue->SetVariableInt("SomethingUniqueTo3", 120); + break; + } +} + +int UTestParticipant::GetDialogueParticipantPriority_Implementation() const +{ + switch(TestNumber) + { + default: + case 0: + return 0; + case 1: + return 100; + case 2: + return -200; + + } +} + +void UTestParticipant::OnDialogueEvent_Implementation(USUDSDialogue* Dialogue, + FName EventName, + const TArray& Arguments) +{ + EventRecords.Add(FEventRecord { EventName, Arguments }); +} + +void UTestParticipant::OnDialogueVariableChanged_Implementation(USUDSDialogue* Dialogue, + FName VariableName, + const FSUDSValue& Value, + bool bFromScript) +{ + SetVarRecords.Add(FSetVarRecord { VariableName, Value, bFromScript }); +} + diff --git a/Plugins/SUDS/Source/SUDSTest/Private/TestParticipant.h b/Plugins/SUDS/Source/SUDSTest/Private/TestParticipant.h new file mode 100644 index 00000000..1b414b52 --- /dev/null +++ b/Plugins/SUDS/Source/SUDSTest/Private/TestParticipant.h @@ -0,0 +1,45 @@ +#pragma once + +#include "CoreMinimal.h" +#include "SUDSParticipant.h" +#include "SUDSValue.h" +#include "UObject/Object.h" +#include "TestParticipant.generated.h" + +/** + * + */ +UCLASS() +class SUDSTEST_API UTestParticipant : public UObject, public ISUDSParticipant +{ + GENERATED_BODY() + +public: + int TestNumber = 0; + + struct FEventRecord + { + FName Name; + TArray Args; + }; + struct FSetVarRecord + { + FName Name; + FSUDSValue Value; + bool bFromScript; + }; + + TArray EventRecords; + TArray SetVarRecords; + + + virtual void OnDialogueStarting_Implementation(USUDSDialogue* Dialogue, FName AtLabel) override; + virtual int GetDialogueParticipantPriority_Implementation() const override; + virtual void OnDialogueEvent_Implementation(USUDSDialogue* Dialogue, + FName EventName, + const TArray& Arguments) override; + virtual void OnDialogueVariableChanged_Implementation(USUDSDialogue* Dialogue, + FName VariableName, + const FSUDSValue& Value, + bool bFromScript) override; +}; diff --git a/Plugins/SUDS/Source/SUDSTest/Private/TestRandom.cpp b/Plugins/SUDS/Source/SUDSTest/Private/TestRandom.cpp new file mode 100644 index 00000000..01ab85ea --- /dev/null +++ b/Plugins/SUDS/Source/SUDSTest/Private/TestRandom.cpp @@ -0,0 +1,342 @@ +#include "SUDSLibrary.h" +#include "SUDSMessageLogger.h" +#include "SUDSScript.h" +#include "SUDSScriptImporter.h" +#include "TestUtils.h" + +UE_DISABLE_OPTIMIZATION + +const FString BasicRandomInput = R"RAWSUD( +Player: Hello +:start +[random] + NPC: Reply when random == 0 +[or] + NPC: Reply when random == 1 +[or] + NPC: Reply when random == 2 +[endrandom] +Player: OK +[goto start] +)RAWSUD"; + + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestRandomBasics, + "SUDSTest.TestRandomBasics", + EAutomationTestFlags::EditorContext | + EAutomationTestFlags::ClientContext | + EAutomationTestFlags::ProductFilter) + + +bool FTestRandomBasics::RunTest(const FString& Parameters) +{ + FSUDSMessageLogger Logger(false); + FSUDSScriptImporter Importer; + TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(BasicRandomInput), BasicRandomInput.Len(), "BasicRandomInput", &Logger, true)); + + auto Script = NewObject(GetTransientPackage(), "Test"); + const ScopedStringTableHolder StringTableHolder; + Importer.PopulateAsset(Script, StringTableHolder.StringTable); + + // Script shouldn't be the owner of the dialogue but it's the only UObject we've got right now so why not + auto Dlg = USUDSLibrary::CreateDialogue(Script, Script); + + // Seed random so we have consistent results + FMath::SRandInit(34); + Dlg->Start(); + + TestDialogueText(this, "Text node", Dlg, "Player", "Hello"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Random node", Dlg, "NPC", "Reply when random == 2"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Final node", Dlg, "Player", "OK"); + + // Should loop, run random again + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Random node", Dlg, "NPC", "Reply when random == 1"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Final node", Dlg, "Player", "OK"); + + // Should loop, run random again + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Random node", Dlg, "NPC", "Reply when random == 0"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Final node", Dlg, "Player", "OK"); + + // Should loop, run random again + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Random node", Dlg, "NPC", "Reply when random == 1"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Final node", Dlg, "Player", "OK"); + + Script->MarkAsGarbage(); + return true; + +} + +const FString NestedRandomInput = R"RAWSUD( +Player: Hello +:start +[random] + NPC: Reply when random == 0 +[or] + [random] + NPC: Reply when random == 1 && subrandom == 0 + [or] + NPC: Reply when random == 1 && subrandom == 1 + [endrandom] +[or] + NPC: Reply when random == 2 +[endrandom] +Player: OK +[goto start] +)RAWSUD"; + + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestRandomNested, + "SUDSTest.TestRandomNested", + EAutomationTestFlags::EditorContext | + EAutomationTestFlags::ClientContext | + EAutomationTestFlags::ProductFilter) + + +bool FTestRandomNested::RunTest(const FString& Parameters) +{ + FSUDSMessageLogger Logger(false); + FSUDSScriptImporter Importer; + TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(NestedRandomInput), NestedRandomInput.Len(), "NestedRandomInput", &Logger, true)); + + auto Script = NewObject(GetTransientPackage(), "Test"); + const ScopedStringTableHolder StringTableHolder; + Importer.PopulateAsset(Script, StringTableHolder.StringTable); + + // Script shouldn't be the owner of the dialogue but it's the only UObject we've got right now so why not + auto Dlg = USUDSLibrary::CreateDialogue(Script, Script); + + // Seed random so we have consistent results + FMath::SRandInit(785); + Dlg->Start(); + + TestDialogueText(this, "Text node", Dlg, "Player", "Hello"); + TestTrue("Continue", Dlg->Continue()); + + TestDialogueText(this, "Random node", Dlg, "NPC", "Reply when random == 0"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Final node", Dlg, "Player", "OK"); + TestTrue("Continue", Dlg->Continue()); + + // Restart + TestDialogueText(this, "Random node", Dlg, "NPC", "Reply when random == 1 && subrandom == 1"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Final node", Dlg, "Player", "OK"); + TestTrue("Continue", Dlg->Continue()); + + // Restart + TestDialogueText(this, "Random node", Dlg, "NPC", "Reply when random == 1 && subrandom == 0"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Final node", Dlg, "Player", "OK"); + TestTrue("Continue", Dlg->Continue()); + + // Restart + TestDialogueText(this, "Random node", Dlg, "NPC", "Reply when random == 2"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Final node", Dlg, "Player", "OK"); + TestTrue("Continue", Dlg->Continue()); + + // Restart + TestDialogueText(this, "Random node", Dlg, "NPC", "Reply when random == 0"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Final node", Dlg, "Player", "OK"); + TestTrue("Continue", Dlg->Continue()); + + Script->MarkAsGarbage(); + return true; + +} + +const FString SiblingRandomInput = R"RAWSUD( +Player: Hello +:start +[random] + NPC: Reply when random == 0 +[or] + NPC: Reply when random == 1 +[or] + NPC: Reply when random == 2 +[endrandom] +[random] + NPC: Second random == 0 +[or] + NPC: Second random == 1 +[or] + NPC: Second random == 2 +[endrandom] +Player: OK +[goto start] +)RAWSUD"; + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestRandomSibling, + "SUDSTest.TestRandomSibling", + EAutomationTestFlags::EditorContext | + EAutomationTestFlags::ClientContext | + EAutomationTestFlags::ProductFilter) +bool FTestRandomSibling::RunTest(const FString& Parameters) +{ + FSUDSMessageLogger Logger(false); + FSUDSScriptImporter Importer; + TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(SiblingRandomInput), SiblingRandomInput.Len(), "SiblingRandomInput", &Logger, true)); + + auto Script = NewObject(GetTransientPackage(), "Test"); + const ScopedStringTableHolder StringTableHolder; + Importer.PopulateAsset(Script, StringTableHolder.StringTable); + + // Script shouldn't be the owner of the dialogue but it's the only UObject we've got right now so why not + auto Dlg = USUDSLibrary::CreateDialogue(Script, Script); + + // Seed random so we have consistent results + FMath::SRandInit(2376); + Dlg->Start(); + + TestDialogueText(this, "Text node", Dlg, "Player", "Hello"); + TestTrue("Continue", Dlg->Continue()); + + + TestDialogueText(this, "Text node", Dlg, "NPC", "Reply when random == 1"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Text node", Dlg, "NPC", "Second random == 0"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Text node", Dlg, "Player", "OK"); + TestTrue("Continue", Dlg->Continue()); + + // Restart + TestDialogueText(this, "Text node", Dlg, "NPC", "Reply when random == 0"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Text node", Dlg, "NPC", "Second random == 2"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Text node", Dlg, "Player", "OK"); + TestTrue("Continue", Dlg->Continue()); + + // Restart + TestDialogueText(this, "Text node", Dlg, "NPC", "Reply when random == 1"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Text node", Dlg, "NPC", "Second random == 2"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Text node", Dlg, "Player", "OK"); + TestTrue("Continue", Dlg->Continue()); + + // Restart + TestDialogueText(this, "Text node", Dlg, "NPC", "Reply when random == 0"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Text node", Dlg, "NPC", "Second random == 0"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Text node", Dlg, "Player", "OK"); + TestTrue("Continue", Dlg->Continue()); + + // Restart + TestDialogueText(this, "Text node", Dlg, "NPC", "Reply when random == 2"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Text node", Dlg, "NPC", "Second random == 2"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Text node", Dlg, "Player", "OK"); + TestTrue("Continue", Dlg->Continue()); + + Script->MarkAsGarbage(); + return true; + +} + + +const FString MixedConditionalChoiceAndRandomInput = R"RAWSUD( +Player: Hello + * First choice + Player: I took the 1.1 choice +[if {x} > 0] + * Second choice (conditional) + [random] + Player: I took the 1.2 choice, random == 0 + [or] + Player: I took the 1.2 choice, random == 1 + [endif] + * Third choice (conditional) + Player: I took the 1.3 choice +[else] + * Second Alt Choice + [random] + Player: I took the alt 1.2 choice, random == 0 + [or] + Player: I took the alt 1.2 choice, random == 1 + [endif] +[endif] + * Common last choice + Player: I took the 1.4 choice + + +)RAWSUD"; + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestRandomMixed, + "SUDSTest.TestRandomMixed", + EAutomationTestFlags::EditorContext | + EAutomationTestFlags::ClientContext | + EAutomationTestFlags::ProductFilter) +bool FTestRandomMixed::RunTest(const FString& Parameters) +{ + FSUDSMessageLogger Logger(false); + FSUDSScriptImporter Importer; + TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(MixedConditionalChoiceAndRandomInput), MixedConditionalChoiceAndRandomInput.Len(), "MixedRandomInput", &Logger, true)); + + auto Script = NewObject(GetTransientPackage(), "Test"); + const ScopedStringTableHolder StringTableHolder; + Importer.PopulateAsset(Script, StringTableHolder.StringTable); + + // Script shouldn't be the owner of the dialogue but it's the only UObject we've got right now so why not + auto Dlg = USUDSLibrary::CreateDialogue(Script, Script); + + // Seed random so we have consistent results + FMath::SRandInit(999); + Dlg->SetVariableInt("x", 5); + Dlg->Start(); + + TestDialogueText(this, "Start node", Dlg, "Player", "Hello"); + // 2 choices from conditional, 2 common + if (TestEqual("Choice count when x=5", Dlg->GetNumberOfChoices(), 4)) + { + TestTrue("Choose 1", Dlg->Choose(1)); + TestDialogueText(this, "Text node", Dlg, "Player", "I took the 1.2 choice, random == 0"); + } + + // Restart, same again but different result if random + Dlg->Restart(false); + TestDialogueText(this, "Start node", Dlg, "Player", "Hello"); + // 2 choices from conditional, 2 common + if (TestEqual("Choice count when x=5", Dlg->GetNumberOfChoices(), 4)) + { + TestTrue("Choose 1", Dlg->Choose(1)); + TestDialogueText(this, "Text node", Dlg, "Player", "I took the 1.2 choice, random == 1"); + } + + // Now change to other conditional path + Dlg->SetVariableInt("x", 0); + + Dlg->Restart(false); + TestDialogueText(this, "Start node", Dlg, "Player", "Hello"); + if (TestEqual("Choice count when x=0", Dlg->GetNumberOfChoices(), 3)) + { + TestTrue("Choose 1", Dlg->Choose(1)); + TestDialogueText(this, "Text node", Dlg, "Player", "I took the alt 1.2 choice, random == 1"); + } + Dlg->Restart(false); + TestDialogueText(this, "Start node", Dlg, "Player", "Hello"); + if (TestEqual("Choice count when x=0", Dlg->GetNumberOfChoices(), 3)) + { + TestTrue("Choose 1", Dlg->Choose(1)); + TestDialogueText(this, "Text node", Dlg, "Player", "I took the alt 1.2 choice, random == 0"); + } + + + + Script->MarkAsGarbage(); + return true; + +} + +UE_ENABLE_OPTIMIZATION \ No newline at end of file diff --git a/Plugins/SUDS/Source/SUDSTest/Private/TestRunning.cpp b/Plugins/SUDS/Source/SUDSTest/Private/TestRunning.cpp new file mode 100644 index 00000000..1d32b299 --- /dev/null +++ b/Plugins/SUDS/Source/SUDSTest/Private/TestRunning.cpp @@ -0,0 +1,492 @@ +#include "SUDSDialogue.h" +#include "SUDSLibrary.h" +#include "SUDSMessageLogger.h" +#include "SUDSScript.h" +#include "SUDSScriptImporter.h" +#include "SUDSSubsystem.h" +#include "TestUtils.h" +#include "Internationalization/Internationalization.h" +#include "Misc/AutomationTest.h" + +UE_DISABLE_OPTIMIZATION + +const FString SimpleRunnerInput = R"RAWSUD( +:start +Player: Hello there +NPC: Salutations fellow human + :choice + * Actually no + NPC: How rude, bye then + [goto end] + * Nested option + :nestedstart + NPC: Some nesting + * Actually bye + Player: Gotta go! + [go to goodbye] + * A fallthrough choice + NPC: This should fall through to latterhalf + * A goto choice + [goto latterhalf] + * Another option + Player: What now? + NPC: This is another fallthrough +:latterhalf +Player: This is the latter half of the discussion +NPC: Yep, sure is + * Go back to choice + NPC: Okay! + [goto choice] + * Return to the start + NPC: Gotcha + [goto start] + * Continue + Player: OK I'd like to carry on now + NPC: Right you are guv, falling through +:goodbye +NPC: Bye! +)RAWSUD"; + +const FString SetVariableRunnerInput = R"RAWSUD( +=== +# Set some vars in header +# Text var with an existing localised ID +[set SpeakerName.Player "Protagonist"] @12345@ +# Text var no localised ID +[set ValetName "Bob"] +// Also test with equals signs +[set SomeFloat = 12.5] +[set SomeCalculatedInt = (3 + 4) * 2] +[set SomeCalculatedBoolean = true or false] +// Test global vars +[set global.SomeGlobalInt 3] +[set global.SomeGlobalFloat = 12.5 * 3] +=== + +Player: Hello +[set SomeInt 99] +# Test that we can use variables in set and that ordering works +[set SomeOtherFloat {SomeFloat} + 10] +[set SomeFloat 43.754] +# Test global values +NPC: Wotcha +Player: Values are: {global.SomeGlobalInt}, {global.SomeGlobalFloat}, {global.GlobalIntSetOutside} +# Test that inserting a set node in between text and choice doesn't break link +[set SomeGender masculine] + * Choice 1 + [set SomeBoolean True] + NPC: Truth + * Choice 2 + NPC: Surprise + [set ValetName "Kate"] + [set SomeGender feminine] + [set someint 101] +Player: Well + +)RAWSUD"; + +const FString FallthroughEdgeCaseInput = R"RAWSUD( +NPC: First line + * Option 1 + NPC: Fallthrough via goto + [goto secondchoice] + * Option 2 + NPC: Fallthrough implicitly + * Option 3 + NPC: Fallthrough implicitly again + +:secondchoice + * Fallthrough 1 + NPC: text 1 + * Fallthrough 2 + NPC: text 2 +)RAWSUD"; + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestSimpleRunning, + "SUDSTest.TestSimpleRunning", + EAutomationTestFlags::EditorContext | + EAutomationTestFlags::ClientContext | + EAutomationTestFlags::ProductFilter) + + + +bool FTestSimpleRunning::RunTest(const FString& Parameters) +{ + FSUDSMessageLogger Logger(false); + FSUDSScriptImporter Importer; + TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(SimpleRunnerInput), SimpleRunnerInput.Len(), "SimpleRunnerInput", &Logger, true)); + + auto Script = NewObject(GetTransientPackage(), "Test"); + const ScopedStringTableHolder StringTableHolder; + Importer.PopulateAsset(Script, StringTableHolder.StringTable); + + // Script shouldn't be the owner of the dialogue but it's the only UObject we've got right now so why not + auto Dlg = USUDSLibrary::CreateDialogue(Script, Script); + Dlg->Start(); + + TestDialogueText(this, "First node", Dlg, "Player", "Hello there"); + TestEqual("First node choices", Dlg->GetNumberOfChoices(), 1); + TestTrue("First node choice text", Dlg->GetChoiceText(0).IsEmpty()); + + TestTrue("Continue", Dlg->Continue()); + + TestDialogueText(this, "Node 2", Dlg, "NPC", "Salutations fellow human"); + TestEqual("Node 2 choices", Dlg->GetNumberOfChoices(), 3); + TestEqual("Node 2 choice text 0", Dlg->GetChoiceText(0).ToString(), "Actually no"); + TestEqual("Node 2 choice text 1", Dlg->GetChoiceText(1).ToString(), "Nested option"); + TestEqual("Node 2 choice text 2", Dlg->GetChoiceText(2).ToString(), "Another option"); + + TestTrue("Choice 1", Dlg->Choose(0)); + TestDialogueText(this, "Choice 1 Text", Dlg, "NPC", "How rude, bye then"); + // Goes straight to end + TestFalse("Choice 1 Follow On", Dlg->Continue()); + TestTrue("Should be at end", Dlg->IsEnded()); + + // Start again + Dlg->Restart(); + TestDialogueText(this, "First node", Dlg, "Player", "Hello there"); + TestEqual("First node choices", Dlg->GetNumberOfChoices(), 1); + TestTrue("First node choice text", Dlg->GetChoiceText(0).IsEmpty()); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Node 2", Dlg, "NPC", "Salutations fellow human"); + + // Confirm we previously chose 0 + TestTrue("Choice 0 taken previously", Dlg->HasChoiceIndexBeenTakenPreviously(0)); + + TestTrue("Choice 2", Dlg->Choose(1)); + TestDialogueText(this, "Choice 2 Text", Dlg, "NPC", "Some nesting"); + TestEqual("Choice 2 nested choices", Dlg->GetNumberOfChoices(), 3); + TestEqual("Choice 2 nested choice text 0", Dlg->GetChoiceText(0).ToString(), "Actually bye"); + TestEqual("Choice 2 nested choice text 1", Dlg->GetChoiceText(1).ToString(), "A fallthrough choice"); + TestEqual("Choice 2 nested choice text 2", Dlg->GetChoiceText(2).ToString(), "A goto choice"); + + TestTrue("Nested choice made", Dlg->Choose(0)); + TestDialogueText(this, "Nested choice made text", Dlg, "Player", "Gotta go!"); + TestTrue("Nested choice follow On", Dlg->Continue()); + TestDialogueText(this, "Nested choice follow on text", Dlg, "NPC", "Bye!"); + TestFalse("Nested choice follow On 2", Dlg->Continue()); + TestTrue("Should be at end", Dlg->IsEnded()); + + // Start again, this time from nested choice, and reset all state + Dlg->Restart(true, "nestedstart"); + TestDialogueText(this, "nestedchoice restart Text", Dlg, "NPC", "Some nesting"); + TestTrue("Nested choice made", Dlg->Choose(1)); + TestDialogueText(this, "Nested choice 2 Text", Dlg, "NPC", "This should fall through to latterhalf"); + TestTrue("Nested choice 2 follow On", Dlg->Continue()); + // Should have fallen through + TestDialogueText(this, "Fallthrough Text", Dlg, "Player", "This is the latter half of the discussion"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Fallthrough Text 2", Dlg, "NPC", "Yep, sure is"); + TestEqual("Fallthrough choices", Dlg->GetNumberOfChoices(), 3); + TestEqual("Fallthrough choice text 0", Dlg->GetChoiceText(0).ToString(), "Go back to choice"); + TestEqual("Fallthrough choice text 1", Dlg->GetChoiceText(1).ToString(), "Return to the start"); + TestEqual("Fallthrough choice text 2", Dlg->GetChoiceText(2).ToString(), "Continue"); + + // Go back to choice + TestTrue("Fallthrough choice made", Dlg->Choose(0)); + TestDialogueText(this, "Fallthrough Choice Text", Dlg, "NPC", "Okay!"); + // The Goto choice should have collapsed the choices such that we can get them immediately + TestEqual("Fallthrough then goto choices", Dlg->GetNumberOfChoices(), 3); + TestEqual("Fallthrough then goto choice text 0", Dlg->GetChoiceText(0).ToString(), "Actually no"); + TestEqual("Fallthrough then goto choice text 1", Dlg->GetChoiceText(1).ToString(), "Nested option"); + TestEqual("Fallthrough then goto choice text 2", Dlg->GetChoiceText(2).ToString(), "Another option"); + + // Restart to test another path + Dlg->Restart(true, "nestedstart"); + TestDialogueText(this, "nestedchoice restart Text", Dlg, "NPC", "Some nesting"); + TestTrue("Nested choice made", Dlg->Choose(2)); + // This should be a direct goto to latterhalf + TestDialogueText(this, "Direct goto", Dlg, "Player", "This is the latter half of the discussion"); + + + Dlg->Restart(true); + TestTrue("Continue", Dlg->Continue()); + TestTrue("Choice 3", Dlg->Choose(2)); + TestDialogueText(this, "Choice 3 Text", Dlg, "Player", "What now?"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Choice 3 Text 2", Dlg, "NPC", "This is another fallthrough"); + TestTrue("Continue", Dlg->Continue()); + // Should have fallen through + TestDialogueText(this, "Direct goto", Dlg, "Player", "This is the latter half of the discussion"); + + Script->MarkAsGarbage(); + return true; +} + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestSetVariableRunning, + "SUDSTest.TestSetVariableRunning", + EAutomationTestFlags::EditorContext | + EAutomationTestFlags::ClientContext | + EAutomationTestFlags::ProductFilter) + + + +bool FTestSetVariableRunning::RunTest(const FString& Parameters) +{ + // Number and plural formatting are locale-specific, so we must set it to a predefined value to produce the expected output + FInternationalization::FCultureStateSnapshot CultureStateSnapshot; + FInternationalization::Get().BackupCultureState(CultureStateSnapshot); + FInternationalization::Get().SetCurrentCulture(TEXT("en-US")); + ON_SCOPE_EXIT + { + FInternationalization::Get().RestoreCultureState(CultureStateSnapshot); + }; + + FSUDSMessageLogger Logger(false); + FSUDSScriptImporter Importer; + TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(SetVariableRunnerInput), SetVariableRunnerInput.Len(), "SetVariableRunnerInput", &Logger, true)); + + auto Script = NewObject(GetTransientPackage(), "Test"); + const ScopedStringTableHolder StringTableHolder; + Importer.PopulateAsset(Script, StringTableHolder.StringTable); + + // We need to use dummy global vars since subsystem doesn't exist + USUDSSubsystem::Test_DummyGlobalVariables.Empty(); + USUDSSubsystem::Test_DummyGlobalVariables.Add("GlobalIntSetOutside", 245); + + // Script shouldn't be the owner of the dialogue but it's the only UObject we've got right now so why not + auto Dlg = USUDSLibrary::CreateDialogue(Script, Script); + Dlg->Start(); + + // Check headers have run & initial variables are set + TestEqual("Header: Player name", Dlg->GetVariableText("SpeakerName.Player").ToString(), "Protagonist"); + TestEqual("Header: Valet name", Dlg->GetVariableText("ValetName").ToString(), "Bob"); + TestEqual("Header: Some float", Dlg->GetVariableFloat("SomeFloat"), 12.5f); + + // Check initial values + TestEqual("Initial: Some int", Dlg->GetVariableInt("SomeInt"), 0); + TestEqual("Initial: Some boolean", Dlg->GetVariableBoolean("SomeBoolean"), false); + TestEqual("Initial: Some gender", Dlg->GetVariableGender("SomeGender"), ETextGender::Neuter); + TestEqual("Initial: calculated int", Dlg->GetVariableInt("SomeCalculatedInt"), 14); + TestTrue("Initial: calculated bool", Dlg->GetVariableBoolean("SomeCalculatedBoolean")); + + + // Test globals + TestFalse("Test not setting local var", Dlg->IsVariableSet("global.SomeGlobalInt")); + TestFalse("Test not setting local var", Dlg->IsVariableSet("global.SomeGlobalFloat")); + TestTrue("Test setting global var", USUDSSubsystem::Test_DummyGlobalVariables.Contains("SomeGlobalInt")); + TestTrue("Test setting global var", USUDSSubsystem::Test_DummyGlobalVariables.Contains("SomeGlobalFloat")); + + TestDialogueText(this, "Node 1", Dlg, "Player", "Hello"); + TestTrue("Continue", Dlg->Continue()); + // Set node should have run + TestEqual("Initial: Some int", Dlg->GetVariableInt("SomeInt"), 99); + TestDialogueText(this, "Node 2", Dlg, "NPC", "Wotcha"); + + // Test that setting a new variable from another variable + 10 worked + TestEqual("Some copied float", Dlg->GetVariableFloat("SomeOtherFloat"), 22.5f); + TestEqual("Original float", Dlg->GetVariableFloat("SomeFloat"), 43.754f); + + // Test globals + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Global text", Dlg, "Player", "Values are: 3, 37.5, 245"); + + TestEqual("Choices count", Dlg->GetNumberOfChoices(), 2); + TestEqual("Choice 1 text", Dlg->GetChoiceText(0).ToString(), "Choice 1"); + TestEqual("Choice 2 text", Dlg->GetChoiceText(1).ToString(), "Choice 2"); + TestTrue("Choose 1", Dlg->Choose(0)); + TestEqual("Gender should be set", Dlg->GetVariableGender("SomeGender"), ETextGender::Masculine); + TestEqual("Some boolean should be set", Dlg->GetVariableBoolean("SomeBoolean"), true); + TestEqual("Valet name should not have changed", Dlg->GetVariableText("ValetName").ToString(), "Bob"); + TestEqual("Gender should not have changed", Dlg->GetVariableGender("SomeGender"), ETextGender::Masculine); + TestDialogueText(this, "Choice end text", Dlg, "NPC", "Truth"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Final node", Dlg, "Player", "Well"); + TestFalse("Continue", Dlg->Continue()); + TestTrue("At end", Dlg->IsEnded()); + + // Restart and DON'T reset state + Dlg->Restart(false); + + // Variables should be the same + // Except for the headers, which will have run again + TestEqual("Player name should have been set again", Dlg->GetVariableText("SpeakerName.Player").ToString(), "Protagonist"); + TestEqual("Valet name should have been set again", Dlg->GetVariableText("ValetName").ToString(), "Bob"); + TestEqual("Some float should have been set again", Dlg->GetVariableFloat("SomeFloat"), 12.5f); + TestEqual("Int should still be set", Dlg->GetVariableInt("SomeInt"), 99); + TestEqual("Gender should still be set", Dlg->GetVariableGender("SomeGender"), ETextGender::Masculine); + TestEqual("Some boolean should still be set", Dlg->GetVariableBoolean("SomeBoolean"), true); + + // Restart and DO reset state + Dlg->Restart(true); + TestEqual("Player name should have been set again", Dlg->GetVariableText("SpeakerName.Player").ToString(), "Protagonist"); + TestEqual("Valet name should have been set again", Dlg->GetVariableText("ValetName").ToString(), "Bob"); + TestEqual("Some float should have been set again", Dlg->GetVariableFloat("SomeFloat"), 12.5f); + TestEqual("Int should have been reset", Dlg->GetVariableInt("SomeInt"), 0); + TestEqual("Gender should have been reset", Dlg->GetVariableGender("SomeGender"), ETextGender::Neuter); + TestEqual("Some boolean should have been reset", Dlg->GetVariableBoolean("SomeBoolean"), false); + + // Try the other path + TestTrue("Continue", Dlg->Continue()); + TestTrue("Continue", Dlg->Continue()); + TestTrue("Choose 2", Dlg->Choose(1)); + TestDialogueText(this, "Choice 2 text", Dlg, "NPC", "Surprise"); + TestEqual("Gender should not be changed yet", Dlg->GetVariableGender("SomeGender"), ETextGender::Masculine); + TestEqual("Valet name should not be changed yet", Dlg->GetVariableText("ValetName").ToString(), "Bob"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Final node", Dlg, "Player", "Well"); + TestEqual("Gender should have changed", Dlg->GetVariableGender("SomeGender"), ETextGender::Feminine); + TestEqual("Valet name should have changed", Dlg->GetVariableText("ValetName").ToString(), "Kate"); + TestEqual("Case insensitive set test", Dlg->GetVariableInt("SomeInt"), 101); + + TestFalse("Continue", Dlg->Continue()); + TestTrue("At end", Dlg->IsEnded()); + + USUDSSubsystem::Test_DummyGlobalVariables.Empty(); + Script->MarkAsGarbage(); + return true; + +} + + +const FString SpeakerNamesInput = R"RAWSUD( +=== +[set SpeakerName.Player "Protagonist"] +[set SpeakerName.NPC "Just Some Guy"] +=== +:start +Player: Hello there +NPC: Salutations fellow human +ThirdGuy: Sup +[set SpeakerName.NPC "Actually A Villain"] +NPC: Aha! +)RAWSUD"; + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestSpeakerNames, + "SUDSTest.TestSpeakerNames", + EAutomationTestFlags::EditorContext | + EAutomationTestFlags::ClientContext | + EAutomationTestFlags::ProductFilter) + +bool FTestSpeakerNames::RunTest(const FString& Parameters) +{ + FSUDSMessageLogger Logger(false); + FSUDSScriptImporter Importer; + TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(SpeakerNamesInput), SpeakerNamesInput.Len(), "SpeakerNamesInput", &Logger, true)); + + auto Script = NewObject(GetTransientPackage(), "Test"); + const ScopedStringTableHolder StringTableHolder; + Importer.PopulateAsset(Script, StringTableHolder.StringTable); + + // Script shouldn't be the owner of the dialogue but it's the only UObject we've got right now so why not + auto Dlg = USUDSLibrary::CreateDialogue(Script, Script); + Dlg->Start(); + + // Speaker ID test + TestDialogueText(this, "Initial text", Dlg, "Player", "Hello there"); + TestEqual("Initial player speaker name", Dlg->GetSpeakerDisplayName().ToString(), "Protagonist"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Text 2", Dlg, "NPC", "Salutations fellow human"); + TestEqual("Initial NPC speaker name", Dlg->GetSpeakerDisplayName().ToString(), "Just Some Guy"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Text 3", Dlg, "ThirdGuy", "Sup"); + TestEqual("Initial ThirdGuy speaker name", Dlg->GetSpeakerDisplayName().ToString(), "ThirdGuy"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Text 4", Dlg, "NPC", "Aha!"); + TestEqual("NPC speaker name should have changed", Dlg->GetSpeakerDisplayName().ToString(), "Actually A Villain"); + + + Script->MarkAsGarbage(); + return true; +} + + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestFallthroughEdgeCase, + "SUDSTest.TestFallthroughEdgeCase", + EAutomationTestFlags::EditorContext | + EAutomationTestFlags::ClientContext | + EAutomationTestFlags::ProductFilter) + + + +bool FTestFallthroughEdgeCase::RunTest(const FString& Parameters) +{ + FSUDSMessageLogger Logger(false); + FSUDSScriptImporter Importer; + TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(FallthroughEdgeCaseInput), FallthroughEdgeCaseInput.Len(), "FallthroughEdgeCaseInput", &Logger, true)); + + auto Script = NewObject(GetTransientPackage(), "Test"); + const ScopedStringTableHolder StringTableHolder; + Importer.PopulateAsset(Script, StringTableHolder.StringTable); + + // Script shouldn't be the owner of the dialogue but it's the only UObject we've got right now so why not + auto Dlg = USUDSLibrary::CreateDialogue(Script, Script); + Dlg->Start(); + + TestDialogueText(this, "Initial text", Dlg, "NPC", "First line"); + TestEqual("Initial num choices", Dlg->GetNumberOfChoices(), 3); + TestTrue("Choice 1", Dlg->Choose(0)); + TestDialogueText(this, "Second text", Dlg, "NPC", "Fallthrough via goto"); + if (TestEqual("Second num choices", Dlg->GetNumberOfChoices(), 2)) + { + TestEqual("Choice text", Dlg->GetChoiceText(0).ToString(), "Fallthrough 1"); + TestEqual("Choice text", Dlg->GetChoiceText(1).ToString(), "Fallthrough 2"); + } + + // Now prove that implicit fallthrough works in middle option + Dlg->Restart(); + TestTrue("Choice 2", Dlg->Choose(1)); + TestDialogueText(this, "Second text", Dlg, "NPC", "Fallthrough implicitly"); + if (TestEqual("Second num choices", Dlg->GetNumberOfChoices(), 2)) + { + TestEqual("Choice text", Dlg->GetChoiceText(0).ToString(), "Fallthrough 1"); + TestEqual("Choice text", Dlg->GetChoiceText(1).ToString(), "Fallthrough 2"); + } + + // Now prove that implicit fallthrough works in last option + Dlg->Restart(); + TestTrue("Choice 2", Dlg->Choose(2)); + TestDialogueText(this, "Second text", Dlg, "NPC", "Fallthrough implicitly again"); + if (TestEqual("Second num choices", Dlg->GetNumberOfChoices(), 2)) + { + TestEqual("Choice text", Dlg->GetChoiceText(0).ToString(), "Fallthrough 1"); + TestEqual("Choice text", Dlg->GetChoiceText(1).ToString(), "Fallthrough 2"); + } + + Script->MarkAsGarbage(); + return true; +} + + +const FString LinesBetweenTextAndChoiceInput = R"RAWSUD( +NPC: Hello +NPC: Here's some choices +[event SomeEvent] + * Option 1 + NPC: This is option 1 + * Option 2 + NPC: This is option 2 +)RAWSUD"; + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestLinesBetweenTextAndChoice, + "SUDSTest.TestLinesBetweenTextAndChoiceInput", + EAutomationTestFlags::EditorContext | + EAutomationTestFlags::ClientContext | + EAutomationTestFlags::ProductFilter) + + + +bool FTestLinesBetweenTextAndChoice::RunTest(const FString& Parameters) +{ + FSUDSMessageLogger Logger(false); + FSUDSScriptImporter Importer; + TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(LinesBetweenTextAndChoiceInput), LinesBetweenTextAndChoiceInput.Len(), "LinesBetweenTextAndChoiceInput", &Logger, true)); + + auto Script = NewObject(GetTransientPackage(), "Test"); + const ScopedStringTableHolder StringTableHolder; + Importer.PopulateAsset(Script, StringTableHolder.StringTable); + + // Script shouldn't be the owner of the dialogue but it's the only UObject we've got right now so why not + auto Dlg = USUDSLibrary::CreateDialogue(Script, Script); + Dlg->Start(); + + TestDialogueText(this, "Initial text", Dlg, "NPC", "Hello"); + TestTrue("Continue", Dlg->Continue()); + TestDialogueText(this, "Initial text", Dlg, "NPC", "Here's some choices"); + TestEqual("Initial num choices", Dlg->GetNumberOfChoices(), 2); + + + return true; +} + +UE_ENABLE_OPTIMIZATION \ No newline at end of file diff --git a/Plugins/SUDS/Source/SUDSTest/Private/TestSaveState.cpp b/Plugins/SUDS/Source/SUDSTest/Private/TestSaveState.cpp new file mode 100644 index 00000000..30bc8f69 --- /dev/null +++ b/Plugins/SUDS/Source/SUDSTest/Private/TestSaveState.cpp @@ -0,0 +1,146 @@ +#include "SUDSDialogue.h" +#include "SUDSLibrary.h" +#include "SUDSMessageLogger.h" +#include "SUDSScript.h" +#include "SUDSScriptImporter.h" +#include "TestUtils.h" +#include "Misc/AutomationTest.h" + +UE_DISABLE_OPTIMIZATION + +const FString SaveStateInput = R"RAWSUD( +=== +# To confirm that when resuming we don't run headers again +[set y = -2.4] +=== +[if {alreadyvisited}] + [goto secondvisit] +[endif] + +NPC: Hello + * First choice + Player: I took the 1.1 choice +[if {x} > 0] + * Second choice (conditional) + Player: I took the 1.2 choice + * Third choice (conditional) + Player: I took the 1.3 choice +[else] + * Second Alt Choice + Player: I took the alt 1.2 choice +[endif] + * Common last choice + Player: I took the 1.4 choice + +[goto goodbye] + +:secondvisit + +NPC: Hello again you! + +[if {y} < 0] + Player: Y is less than 0 + NPC: How interesting + * You don't sound that interested + NPC: Well I was trying to be nice + [if {x} == 0] + * Also x is zero + NPC: Fascinating + [endif] + * Well, better be off + [goto goodbye] +[elseif {y} == 0] + Player: Y is zero + NPC: So is my interest level + Player: Rude + +[else] + Player: Who knows what Y is anyway + [if {ponderous}] + * Who knows what anything is? + NPC: Get out + [endif] + [if {y} > 4.99] + * It's more than I can count on one hand + NPC: Well, that helps + [else] + * It's kind of small though + NPC: Much like your brain + [endif] + * I'm done with this +[endif] + +Player: This is some fallthrough text + +:goodbye +NPC: Bye +)RAWSUD"; + + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestSaveState, + "SUDSTest.TestSaveState", + EAutomationTestFlags::EditorContext | + EAutomationTestFlags::ClientContext | + EAutomationTestFlags::ProductFilter) + + + +bool FTestSaveState::RunTest(const FString& Parameters) +{ + FSUDSMessageLogger Logger(false); + FSUDSScriptImporter Importer; + TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(SaveStateInput), SaveStateInput.Len(), "SaveStateInput", &Logger, true)); + + auto Script = NewObject(GetTransientPackage(), "Test"); + const ScopedStringTableHolder StringTableHolder; + Importer.PopulateAsset(Script, StringTableHolder.StringTable); + + // Script shouldn't be the owner of the dialogue but it's the only UObject we've got right now so why not + auto Dlg = USUDSLibrary::CreateDialogue(Script, Script); + // Set x before start + // No point settings y because that gets changed by headers + Dlg->SetVariableInt("x", 5); + Dlg->Start(); + + TestDialogueText(this, "Text Node", Dlg, "NPC", "Hello"); + if (!TestEqual("Num choices", Dlg->GetNumberOfChoices(), 4)) + return true; + + TestEqual("ChoiceText", Dlg->GetChoiceText(0).ToString(), "First choice"); + TestEqual("ChoiceText", Dlg->GetChoiceText(1).ToString(), "Second choice (conditional)"); + TestEqual("ChoiceText", Dlg->GetChoiceText(2).ToString(), "Third choice (conditional)"); + TestEqual("ChoiceText", Dlg->GetChoiceText(3).ToString(), "Common last choice"); + TestTrue("Choose", Dlg->Choose(2)); + TestDialogueText(this, "Text node", Dlg, "Player", "I took the 1.3 choice"); + + // Set value of y to test it's retained, and not reset by running headers + Dlg->SetVariableFloat("y", 23.5f); + + // Save it here + auto SaveState = Dlg->GetSavedState(); + + // Re-construct the dialogue & restore + auto Dlg2 = USUDSLibrary::CreateDialogue(Script, Script); + Dlg2->RestoreSavedState(SaveState); + + // We should be back at the same point + TestDialogueText(this, "Text node", Dlg2, "Player", "I took the 1.3 choice"); + // Check vars + TestEqual("x value", Dlg2->GetVariableInt("x"), 5); + TestEqual("y value", Dlg2->GetVariableFloat("y"), 23.5f); + TestTrue("Continue", Dlg2->Continue()); + TestDialogueText(this, "Text node", Dlg2, "NPC", "Bye"); + + // Restart to check choices were remembered + Dlg2->Restart(); + TestDialogueText(this, "Text Node", Dlg2, "NPC", "Hello"); + TestFalse("Choice not taken", Dlg2->HasChoiceIndexBeenTakenPreviously(0)); + TestFalse("Choice not taken", Dlg2->HasChoiceIndexBeenTakenPreviously(1)); + TestTrue("Choice not taken", Dlg2->HasChoiceIndexBeenTakenPreviously(2)); + TestFalse("Choice not taken", Dlg2->HasChoiceIndexBeenTakenPreviously(3)); + + Script->MarkAsGarbage(); + return true; +} + +UE_ENABLE_OPTIMIZATION \ No newline at end of file diff --git a/Plugins/SUDS/Source/SUDSTest/Private/TestUserMetadata.cpp b/Plugins/SUDS/Source/SUDSTest/Private/TestUserMetadata.cpp new file mode 100644 index 00000000..7f20aca4 --- /dev/null +++ b/Plugins/SUDS/Source/SUDSTest/Private/TestUserMetadata.cpp @@ -0,0 +1,124 @@ +#include "SUDSDialogue.h" +#include "SUDSLibrary.h" +#include "SUDSMessageLogger.h" +#include "SUDSScript.h" +#include "SUDSScriptImporter.h" +#include "TestUtils.h" +#include "Misc/AutomationTest.h" + +UE_DISABLE_OPTIMIZATION + +// Assign known keys to the strings so we can detect +const FString UserMetadataInput = R"RAWSUD( +# No metadata +Player: Good day sir +#% IsGreeting = true +NPC: Salutations to you too +#% IntValue = 1 +#% Politeness 3.142 +Player: What are we doing today? +# Test a variable-derived, expression metadata +[set IntVariable 2] +#% IntValue = {IntVariable} + 3 +#% LookMumNoEquals `OK_Dear` +NPC: Looks like we're doing some testing. How about a choice? + #% RequirePoshness = 10 + * Choice 1 + Player: Capital, old chap + #% RequirePoshness = 15 + #% RequireTopHat = true + * Choice 2 + Player: Top hole, dear boy + * Choice 3 + Player: OK then +)RAWSUD"; + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestUserMetadata, + "SUDSTest.TestUserMetadata", + EAutomationTestFlags::EditorContext | + EAutomationTestFlags::ClientContext | + EAutomationTestFlags::ProductFilter) + + + +bool FTestUserMetadata::RunTest(const FString& Parameters) +{ + FSUDSMessageLogger Logger(false); + FSUDSScriptImporter Importer; + TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(UserMetadataInput), UserMetadataInput.Len(), "UserMetadataInput", &Logger, true)); + + auto Script = NewObject(GetTransientPackage(), "Test"); + const ScopedStringTableHolder StringTableHolder; + Importer.PopulateAsset(Script, StringTableHolder.StringTable); + + // Script shouldn't be the owner of the dialogue but it's the only UObject we've got right now so why not + auto Dlg = USUDSLibrary::CreateDialogue(Script, Script); + + Dlg->Start(); + + TestDialogueText(this, "First node", Dlg, "Player", "Good day sir"); + TestEqual("Check metadata empty", Dlg->GetAllSpeakerLineUserMetadata().Num(), 0); + + Dlg->Continue(); + TestDialogueText(this, "Next", Dlg, "NPC", "Salutations to you too"); + FSUDSValue Actual = Dlg->GetSpeakerLineUserMetadata(FName("IsGreeting")); + if (TestTrue("Boolean test", Actual.GetType() == ESUDSValueType::Boolean)) + { + TestEqual("Boolean test", Actual.GetBooleanValue(), true); + } + + Dlg->Continue(); + TestDialogueText(this, "Next", Dlg, "Player", "What are we doing today?"); + Actual = Dlg->GetSpeakerLineUserMetadata(FName("IntValue")); + if (TestTrue("Int test", Actual.GetType() == ESUDSValueType::Int)) + { + TestEqual("Int test", Actual.GetIntValue(), 1); + } + Actual = Dlg->GetSpeakerLineUserMetadata(FName("Politeness")); + if (TestTrue("Float test", Actual.GetType() == ESUDSValueType::Float)) + { + TestEqual("Float test", Actual.GetFloatValue(), 3.142f); + } + Dlg->Continue(); + TestDialogueText(this, "Next", Dlg, "NPC", "Looks like we're doing some testing. How about a choice?"); + Actual = Dlg->GetSpeakerLineUserMetadata(FName("IntValue")); + if (TestTrue("Expression test", Actual.GetType() == ESUDSValueType::Int)) + { + TestEqual("Expression test", Actual.GetIntValue(), 5); + } + Actual = Dlg->GetSpeakerLineUserMetadata(FName("LookMumNoEquals")); + if (TestTrue("Name & no equals test", Actual.GetType() == ESUDSValueType::Name)) + { + TestEqual("Name & no equals test", Actual.GetNameValue(), FName("OK_Dear")); + } + + if (TestEqual("Choice count", Dlg->GetNumberOfChoices(), 3)) + { + Actual = Dlg->GetChoiceUserMetadata(0, FName("RequirePoshness")); + if (TestTrue("Choice 0 test", Actual.GetType() == ESUDSValueType::Int)) + { + TestEqual("Choice 0 test", Actual.GetIntValue(), 10); + } + Actual = Dlg->GetChoiceUserMetadata(1, FName("RequirePoshness")); + if (TestTrue("Choice 1 test A", Actual.GetType() == ESUDSValueType::Int)) + { + TestEqual("Choice 1 test A", Actual.GetIntValue(), 15); + } + Actual = Dlg->GetChoiceUserMetadata(1, FName("RequireTopHat")); + if (TestTrue("Choice 1 test B", Actual.GetType() == ESUDSValueType::Boolean)) + { + TestEqual("Choice 1 test B", Actual.GetBooleanValue(), true); + } + auto Choice1All = Dlg->GetAllChoiceUserMetadata(1); + TestEqual("Choice 1 test count", Choice1All.Num(), 2); + TestEqual("Choice 2 test", Dlg->GetAllChoiceUserMetadata(2).Num(), 0); + + } + + + Script->MarkAsGarbage(); + return true; + +} + +UE_ENABLE_OPTIMIZATION diff --git a/Plugins/SUDS/Source/SUDSTest/Private/TestUtils.h b/Plugins/SUDS/Source/SUDSTest/Private/TestUtils.h new file mode 100644 index 00000000..cff3484c --- /dev/null +++ b/Plugins/SUDS/Source/SUDSTest/Private/TestUtils.h @@ -0,0 +1,297 @@ +#pragma once +#include "SUDSDialogue.h" +#include "SUDSScriptNode.h" +#include "SUDSScriptNodeText.h" +#include "Internationalization/StringTable.h" +#include "Internationalization/StringTableRegistry.h" +#include "Misc/AutomationTest.h" +#include "UObject/Package.h" + +FORCEINLINE void TestDialogueText(FAutomationTestBase* T, const FString& NameForTest, USUDSDialogue* D, const FString& SpeakerID, const FString& Text) +{ + T->TestEqual(NameForTest, D->GetSpeakerID(), SpeakerID); + T->TestEqual(NameForTest, D->GetText().ToString(), Text); + +} + +FORCEINLINE bool TestParsedText(FAutomationTestBase* T, const FString& NameForTest, const FSUDSParsedNode* Node, const FString& Speaker, const FString& Text) +{ + if (T->TestNotNull(NameForTest, Node)) + { + T->TestEqual(NameForTest, Node->NodeType, ESUDSParsedNodeType::Text); + T->TestEqual(NameForTest, Node->Identifier, Speaker); + T->TestEqual(NameForTest, Node->Text, Text); + return true; + } + return false; +} + +FORCEINLINE bool TestArgValue(FAutomationTestBase* T, const FString& NameForTest, const FSUDSValue& Actual, int Expected) +{ + if (T->TestEqual(NameForTest, Actual.GetType(), ESUDSValueType::Int)) + { + return T->TestEqual(NameForTest, Actual.GetIntValue(), Expected); + } + return false; +} +FORCEINLINE bool TestArgValue(FAutomationTestBase* T, const FString& NameForTest, const FSUDSValue& Actual, float Expected) +{ + if (T->TestEqual(NameForTest, Actual.GetType(), ESUDSValueType::Float)) + { + return T->TestEqual(NameForTest, Actual.GetFloatValue(), Expected); + } + return false; +} +FORCEINLINE bool TestArgValue(FAutomationTestBase* T, const FString& NameForTest, const FSUDSValue& Actual, ETextGender Expected) +{ + if (T->TestEqual(NameForTest, Actual.GetType(), ESUDSValueType::Gender)) + { + return T->TestEqual(NameForTest, Actual.GetGenderValue(), Expected); + } + return false; +} +FORCEINLINE bool TestArgValue(FAutomationTestBase* T, const FString& NameForTest, const FSUDSValue& Actual, const FString& Expected) +{ + if (T->TestEqual(NameForTest, Actual.GetType(), ESUDSValueType::Text)) + { + return T->TestEqual(NameForTest, Actual.GetTextValue().ToString(), Expected); + } + return false; +} + +template +FORCEINLINE bool TestParsedSetLiteral(FAutomationTestBase* T, const FString& NameForTest, const FSUDSParsedNode* Node, const FString& VarName, V Literal) +{ + if (T->TestNotNull(NameForTest, Node)) + { + T->TestEqual(NameForTest, Node->NodeType, ESUDSParsedNodeType::SetVariable); + T->TestEqual(NameForTest, Node->Identifier, VarName); + if (T->TestTrue(NameForTest, Node->Expression.IsLiteral())) + { + TestArgValue(T, NameForTest,Node->Expression.GetLiteralValue(), Literal); + } + return true; + } + return false; + +} + +// Explicit bool version of the above since otherwise it gets converted to int & fails +FORCEINLINE bool TestParsedSetLiteral(FAutomationTestBase* T, const FString& NameForTest, const FSUDSParsedNode* Node, const FString& VarName, bool Literal) +{ + if (T->TestNotNull(NameForTest, Node)) + { + T->TestEqual(NameForTest, Node->NodeType, ESUDSParsedNodeType::SetVariable); + T->TestEqual(NameForTest, Node->Identifier, VarName); + if (T->TestTrue(NameForTest, Node->Expression.IsLiteral())) + { + T->TestEqual(NameForTest,Node->Expression.GetBooleanLiteralValue(), Literal); + } + + return true; + } + return false; + +} + +FORCEINLINE bool TestParsedSetLiteral(FAutomationTestBase* T, const FString& NameForTest, const FSUDSParsedNode* Node, const FString& VarName, const FName& Name) +{ + if (T->TestNotNull(NameForTest, Node)) + { + T->TestEqual(NameForTest, Node->NodeType, ESUDSParsedNodeType::SetVariable); + T->TestEqual(NameForTest, Node->Identifier, VarName); + if (T->TestTrue(NameForTest, Node->Expression.IsLiteral())) + { + T->TestEqual(NameForTest,Node->Expression.GetNameLiteralValue(), Name); + } + + return true; + } + return false; + +} + +FORCEINLINE bool TestGetParsedNextNode(FAutomationTestBase* T, const FString& NameForTest, const FSUDSParsedNode* Node, FSUDSScriptImporter& Importer, bool bIsHeader, const FSUDSParsedNode** OutNextNode) +{ + if (T->TestNotNull(NameForTest, Node)) + { + if (T->TestEqual(NameForTest, Node->Edges.Num(), 1)) + { + const int NextNodeIdx = Node->Edges[0].TargetNodeIdx; + *OutNextNode = bIsHeader ? Importer.GetHeaderNode(NextNodeIdx) : Importer.GetNode(NextNodeIdx); + return true; + } + } + *OutNextNode = nullptr; + return false; +} +FORCEINLINE bool TestParsedChoice(FAutomationTestBase* T, const FString& NameForTest, const FSUDSParsedNode* Node, int ExpectedNumChoices) +{ + if (T->TestNotNull(NameForTest, Node)) + { + T->TestEqual(NameForTest, Node->NodeType, ESUDSParsedNodeType::Choice); + T->TestEqual(NameForTest, Node->Edges.Num(), ExpectedNumChoices); + return true; + } + return false; +} +FORCEINLINE bool TestParsedSelect(FAutomationTestBase* T, const FString& NameForTest, const FSUDSParsedNode* Node, int ExpectedNumEdges) +{ + if (T->TestNotNull(NameForTest, Node)) + { + T->TestEqual(NameForTest, Node->NodeType, ESUDSParsedNodeType::Select); + T->TestEqual(NameForTest, Node->Edges.Num(), ExpectedNumEdges); + return true; + } + return false; +} + +FORCEINLINE bool TestParsedChoiceEdge(FAutomationTestBase* T, const FString& NameForTest, const FSUDSParsedNode* Node, int EdgeIndex, const FString& Text, FSUDSScriptImporter& Importer, const FSUDSParsedNode** OutNode) +{ + *OutNode = nullptr; + if (Node && Node->Edges.Num() > EdgeIndex) + { + auto& Edge = Node->Edges[EdgeIndex]; + T->TestEqual(NameForTest, Edge.Text, Text); + const int Idx = Edge.TargetNodeIdx; + *OutNode = Importer.GetNode(Idx); + return true; + } + + return false; + +} + +FORCEINLINE bool TestParsedSelectEdge(FAutomationTestBase* T, const FString& NameForTest, const FSUDSParsedNode* Node, int EdgeIndex, const FString& ConditionStr, FSUDSScriptImporter& Importer, const FSUDSParsedNode** OutNode) +{ + *OutNode = nullptr; + if (Node && Node->Edges.Num() > EdgeIndex) + { + auto& Edge = Node->Edges[EdgeIndex]; + T->TestEqual(NameForTest, Edge.ConditionExpression.GetSourceString(), ConditionStr); + const int Idx = Edge.TargetNodeIdx; + *OutNode = Importer.GetNode(Idx); + return true; + } + + return false; + +} + +FORCEINLINE bool TestParsedGoto(FAutomationTestBase* T, const FString& NameForTest, const FSUDSParsedNode* Node, FSUDSScriptImporter& Importer, const FSUDSParsedNode** OutNode) +{ + if (T->TestNotNull(NameForTest, Node)) + { + T->TestEqual(NameForTest, Node->NodeType, ESUDSParsedNodeType::Goto); + const int Target = Importer.GetGotoTargetNodeIndex(Node->Identifier); + *OutNode = Importer.GetNode(Target); + return true; + } + return false; +} + +FORCEINLINE bool TestTextNode(FAutomationTestBase* T, const FString& NameForTest, const USUDSScriptNode* Node, const FString& Speaker, const FString& Text) +{ + if (T->TestNotNull(NameForTest, Node)) + { + T->TestEqual(NameForTest, Node->GetNodeType(), ESUDSScriptNodeType::Text); + if (auto TextNode = Cast(Node)) + { + T->TestEqual(NameForTest, TextNode->GetSpeakerID(), Speaker); + T->TestEqual(NameForTest, TextNode->GetText().ToString(), Text); + } + return true; + } + return false; +} + +FORCEINLINE bool TestEdge(FAutomationTestBase* T, const FString& NameForTest, USUDSScriptNode* Node, int EdgeIndex, USUDSScriptNode** OutNode) +{ + *OutNode = nullptr; + if (Node && Node->GetEdgeCount() > EdgeIndex) + { + if (auto Edge = Node->GetEdge(EdgeIndex)) + { + *OutNode = Edge->GetTargetNode().Get(); + return T->TestNotNull(NameForTest, *OutNode); + } + } + + return false; + +} + +FORCEINLINE bool TestChoiceNode(FAutomationTestBase* T, const FString& NameForTest, const USUDSScriptNode* Node, int NumChoices) +{ + if (T->TestNotNull(NameForTest, Node)) + { + T->TestEqual(NameForTest, Node->GetNodeType(), ESUDSScriptNodeType::Choice); + return T->TestEqual(NameForTest, Node->GetEdgeCount(), NumChoices); + } + return false; +} + +FORCEINLINE bool TestSelectNode(FAutomationTestBase* T, const FString& NameForTest, const USUDSScriptNode* Node, int NumEdges) +{ + if (T->TestNotNull(NameForTest, Node)) + { + T->TestEqual(NameForTest, Node->GetNodeType(), ESUDSScriptNodeType::Select); + return T->TestEqual(NameForTest, Node->GetEdgeCount(), NumEdges); + } + return false; +} + +FORCEINLINE bool TestChoiceEdge(FAutomationTestBase* T, const FString& NameForTest, USUDSScriptNode* Node, int EdgeIndex, const FString& Text, USUDSScriptNode** OutNode) +{ + *OutNode = nullptr; + if (Node && Node->GetEdgeCount() > EdgeIndex) + { + if (auto Edge = Node->GetEdge(EdgeIndex)) + { + T->TestEqual(NameForTest, Edge->GetText().ToString(), Text); + *OutNode = Edge->GetTargetNode().Get(); + return T->TestNotNull(NameForTest, *OutNode); + } + } + + return false; + +} + +FORCEINLINE bool TestSelectEdge(FAutomationTestBase* T, const FString& NameForTest, USUDSScriptNode* Node, int EdgeIndex, const FString& ConditionStr, USUDSScriptNode** OutNode) +{ + *OutNode = nullptr; + if (Node && Node->GetEdgeCount() > EdgeIndex) + { + if (auto Edge = Node->GetEdge(EdgeIndex)) + { + T->TestEqual(NameForTest, Edge->GetCondition().GetSourceString(), ConditionStr); + *OutNode = Edge->GetTargetNode().Get(); + return T->TestNotNull(NameForTest, *OutNode); + } + } + + return false; + +} + +// Helper to provide a string table just in scope +struct ScopedStringTableHolder +{ +public: + + UStringTable* StringTable; + + ScopedStringTableHolder() + { + StringTable = NewObject(GetTransientPackage(), "TestStrings"); + } + + ~ScopedStringTableHolder() + { + // Tidy up string table + // UStringTable constructor registered this table + FStringTableRegistry::Get().UnregisterStringTable(StringTable->GetStringTableId()); + } + +}; diff --git a/Plugins/SUDS/Source/SUDSTest/SUDSTest.Build.cs b/Plugins/SUDS/Source/SUDSTest/SUDSTest.Build.cs new file mode 100644 index 00000000..b048aaad --- /dev/null +++ b/Plugins/SUDS/Source/SUDSTest/SUDSTest.Build.cs @@ -0,0 +1,30 @@ +// Copyright 1998-2019 Epic Games, Inc. All Rights Reserved. + +using UnrealBuildTool; + +public class SUDSTest : ModuleRules +{ + public SUDSTest(ReadOnlyTargetRules Target) : base(Target) + { + PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs; + + PrivateDependencyModuleNames.AddRange( + new string[] + { + "Core", + "CoreUObject", + "Engine", + "SUDS", + "SUDSEditor" + } + ); + + // Uncomment if you are using Slate UI + // PrivateDependencyModuleNames.AddRange(new string[] { "Slate", "SlateCore" }); + + // Uncomment if you are using online features + // PrivateDependencyModuleNames.Add("OnlineSubsystem"); + + // To include OnlineSubsystemSteam, add it to the plugins section in your uproject file with the Enabled attribute set to true + } +} diff --git a/Plugins/StatForge/Resources/Icon128.png b/Plugins/StatForge/Resources/Icon128.png new file mode 100644 index 00000000..26245f6a --- /dev/null +++ b/Plugins/StatForge/Resources/Icon128.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f7239efaeefbd82de33ebe18518e50de075ea4188a468a9e4991396433d2275f +size 12699 diff --git a/Plugins/StatForge/Source/StatForge/Private/Components/StatForgeComponent.cpp b/Plugins/StatForge/Source/StatForge/Private/Components/StatForgeComponent.cpp new file mode 100644 index 00000000..48c485dc --- /dev/null +++ b/Plugins/StatForge/Source/StatForge/Private/Components/StatForgeComponent.cpp @@ -0,0 +1,381 @@ +// Fill out your copyright notice in the Description page of Project Settings. + + +#include "Components/StatForgeComponent.h" +#include "Config/StatForgeDeveloperSettings.h" +#include "Data/GameplayStatEffect.h" + + +FActiveGameplayStatEffect FActiveGameplayStatEffect::CreateNew(UObject* SourceObject, UGameplayStatEffect* Effect, int32 Stacks) +{ + if (!Effect || !SourceObject) return FActiveGameplayStatEffect(); + + FActiveGameplayStatEffect ActiveEffect; + ActiveEffect.SourceObject = SourceObject; + ActiveEffect.GameplayEffectClass = Effect->GetClass(); + ActiveEffect.StackCount = Stacks; + ActiveEffect.GameplayEffectInstance = Effect; + ActiveEffect.RemainingDuration = Effect->Duration; + ActiveEffect.NextTickDuration = Effect->TickPeriod; + ActiveEffect.bIsTicking = ActiveEffect.NextTickDuration > 0.f; + ActiveEffect.Handle = FGuid::NewGuid(); + + return ActiveEffect; +} + +UStatForgeComponent::UStatForgeComponent() +{ + PrimaryComponentTick.bCanEverTick = true; +} + +float UStatForgeComponent::GetStatValue(const FName StatName) const +{ + for (const FStatRuntimeDef& Def : StatArray) + { + if (Def.StatDef.StatName == StatName) + { + return Def.CurrentValue; + } + } + + return 0.f; +} + +void UStatForgeComponent::BeginPlay() +{ + Super::BeginPlay(); + + TMap DataValueMap; + + if (const UStatForgeDeveloperSettings* DeveloperSettings = GetDefault()) + { + for (const auto& StatPair : InitialStatValueArray) + { + FStatDef Def = DeveloperSettings->GetStatByName(StatPair.Key); + if (Def.IsValid()) + { + StatArray.Add(FStatRuntimeDef(Def)); + FGameplayStatEffectMod Mod; + Mod.AffectedStat = StatPair.Key; + Mod.ModOperation = EModOperation::Override; + Mod.CalculationType = EGameplayStatEffectModCalculation::Flat; + Mod.FlatValue = StatPair.Value; + ApplyModToStat(Mod, DataValueMap); + } + } + } + + for (const TSubclassOf& Effect : InitialEffectArray) + { + ApplyGameplayStatEffect(Effect, DataValueMap); + } +} + +void UStatForgeComponent::TickComponent(float DeltaTime, enum ELevelTick TickType, + FActorComponentTickFunction* ThisTickFunction) +{ + Super::TickComponent(DeltaTime, TickType, ThisTickFunction); + TickEffects(DeltaTime); +} + +void UStatForgeComponent::IsStat(FName StatName, FName CheckedName, EIsStatResult& Result) +{ + Result = StatName.IsEqual(CheckedName)? EIsStatResult::Yes : EIsStatResult::No; +} + +void UStatForgeComponent::Internal_ApplyGameplayStatEffect(UGameplayStatEffect* Effect) +{ + if (!CanApplyEffect(Effect)) return; + + PreGameplayEffectExecute(Effect); + for (const FGameplayStatEffectMod& Modifier : Effect->Modifiers) + { + ApplyModToStat(Modifier, Effect->GetDataValueMap()); + } + PostGameplayEffectExecute(Effect); +} + +void UStatForgeComponent::ApplyModToStat(const FGameplayStatEffectMod& Mod, const TMap DataValueMap) +{ + FStatRuntimeDef* StatDef = GetStatRef(Mod.AffectedStat); + if (!StatDef) return; + + PreStatModify(StatDef->StatDef.StatName); + const float OldValue = StatDef->BaseValue; + const float ModValue = Mod.GetModValue(this, DataValueMap); + switch (Mod.ModOperation) + { + case EModOperation::Add: + StatDef->BaseValue += ModValue; + break; + case EModOperation::MultiplyAdditive: + case EModOperation::MultiplyCompound: + StatDef->BaseValue *= ModValue; + break; + case EModOperation::Override: + StatDef->BaseValue = ModValue; + break; + } + RecalculateStatCurrentValue(Mod.AffectedStat, OldValue); +} + +void UStatForgeComponent::RecalculateStatCurrentValue(const FName StatName, const float OldValue) +{ + FStatRuntimeDef* StatDef = GetStatRef(StatName); + if (!StatDef) return; + + const float BaseValue = StatDef->BaseValue; + float AdditiveSum = 0.f; + float MultiAddSum = 0.f; + float MultiCompound = 1.f; + bool bHasOverride = false; + float OverrideValue = 0.f; + for (FActiveGameplayStatEffect& Effect : ActiveEffectsArray) + { + if (Effect.bIsTicking) continue; + + for (auto Mod : Effect.GameplayEffectInstance->Modifiers) + { + if (Mod.AffectedStat != StatName) continue; + + const float StackCount = Effect.StackCount; + const float ModValue = Mod.GetModValue(this, Effect.GameplayEffectInstance->GetDataValueMap()); + switch (Mod.ModOperation) { + case EModOperation::Add: + { + AdditiveSum += (ModValue * StackCount); + break; + } + case EModOperation::MultiplyAdditive: + { + MultiAddSum += (ModValue * StackCount); + break; + } + case EModOperation::MultiplyCompound: + { + MultiCompound *= (ModValue * StackCount); + break; + } + case EModOperation::Override: + { + bHasOverride = true; + OverrideValue = ModValue; + break; + } + } + } + + if (bHasOverride) + { + break; + } + } + + float Result = (BaseValue + AdditiveSum) * (1.f + MultiAddSum) * MultiCompound; + if (bHasOverride) + { + Result = OverrideValue; + } + + StatDef->CurrentValue = Result; + PostStatModify(StatName); + OnStatChanged.Broadcast(StatName, OldValue, StatDef->CurrentValue); +} + +FGuid UStatForgeComponent::ApplyGameplayStatEffect(const TSubclassOf EffectClass, TMap DataValueMap, int32 Stacks) +{ + if (!EffectClass) return FGuid(); + + UGameplayStatEffect* Effect = NewObject(this, EffectClass); + check(Effect); + + Effect->AppendDataValueMap(DataValueMap); + FGuid GuidToReturn; + + if (Effect->Duration == GameplayStatEffectDuration_Instant) + { + Internal_ApplyGameplayStatEffect(Effect); + } + else + { + FActiveGameplayStatEffect ActiveEffect = FActiveGameplayStatEffect::CreateNew(this, Effect, Stacks); + GuidToReturn = ActiveEffect.Handle; + ActiveEffectsArray.Add(ActiveEffect); + AddGameplayTags(Effect->CharacterTags); + + for (const FName StatName : GetAffectedStatsFromEffect(ActiveEffect.GameplayEffectInstance)) + { + const float OldValue = GetStatValue(StatName); + RecalculateStatCurrentValue(StatName, OldValue); + } + } + + return GuidToReturn; +} + +bool UStatForgeComponent::RemoveGameplayStatEffect(FGuid EffectId) +{ + for (int32 i = ActiveEffectsArray.Num() - 1; i >= 0; --i) + { + if (ActiveEffectsArray[i].Handle == EffectId) + { + RemoveGameplayTags(ActiveEffectsArray[i].GameplayEffectInstance->CharacterTags); + TArray AffectedStats = GetAffectedStatsFromEffect(ActiveEffectsArray[i].GameplayEffectInstance.Get()); + ActiveEffectsArray.RemoveAt(i); + + for (const FName& Stat : AffectedStats) + { + const float OldValue = GetStatValue(Stat); + RecalculateStatCurrentValue(Stat, OldValue); + } + + return true; + } + } + + return false; +} + +bool UStatForgeComponent::HasStat(const FName& StatName) const +{ + for (const FStatRuntimeDef& Stat : StatArray) + { + if (Stat.StatDef.StatName == StatName) + { + return true; + } + } + + return false; +} + +void UStatForgeComponent::AddGameplayTag(const FGameplayTag& NewTag) +{ + if (NewTag.IsValid()) + { + CharacterGameplayTags.AddTag(NewTag); + NotifyGameplayTagsChanged(); + } +} + +void UStatForgeComponent::AddGameplayTags(const FGameplayTagContainer& NewTags) +{ + if(NewTags.Num() > 0) + { + CharacterGameplayTags.AppendTags(NewTags); + NotifyGameplayTagsChanged(); + } +} + +bool UStatForgeComponent::RemoveGameplayTag(const FGameplayTag& NewTag) +{ + if (NewTag.IsValid()) + { + CharacterGameplayTags.RemoveTag(NewTag); + NotifyGameplayTagsChanged(); + return true; + } + return false; +} + +void UStatForgeComponent::RemoveGameplayTags(const FGameplayTagContainer& NewTags) +{ + if(NewTags.Num() > 0) + { + CharacterGameplayTags.RemoveTags(NewTags); + NotifyGameplayTagsChanged(); + } +} + +void UStatForgeComponent::NotifyGameplayTagsChanged() const +{ + OnTagsChanged.Broadcast(); +} + +TArray UStatForgeComponent::GetAffectedStatsFromEffect(UGameplayStatEffect* Effect) +{ + TArray ChangedStats; + for (auto Mod : Effect->Modifiers) + { + ChangedStats.AddUnique(Mod.AffectedStat); + } + + return ChangedStats; +} + +FStatRuntimeDef* UStatForgeComponent::GetStatRef(const FName StatName) +{ + for (FStatRuntimeDef& Def : StatArray) + { + if (Def.StatDef.StatName == StatName) + { + return &Def; + } + } + + return nullptr; +} + +void UStatForgeComponent::TickEffects(float DeltaTime) +{ + TArray StatNames; + for (int32 i = ActiveEffectsArray.Num() - 1; i >= 0; --i) + { + StatNames.Append(GetAffectedStatsFromEffect(ActiveEffectsArray[i].GameplayEffectInstance)); + + if (!CanTickEffect(ActiveEffectsArray[i].GameplayEffectInstance.Get())) + { + ActiveEffectsArray[i].RemainingDuration = 0; + } + else + { + ActiveEffectsArray[i].RemainingDuration -= DeltaTime; + } + + if (ActiveEffectsArray[i].RemainingDuration <= 0.f) + { + RemoveGameplayStatEffect(ActiveEffectsArray[i].Handle); + + continue; + } + + if (ActiveEffectsArray[i].GameplayEffectInstance->TickPeriod > 0.f) + { + ActiveEffectsArray[i].NextTickDuration -= DeltaTime; + if (ActiveEffectsArray[i].NextTickDuration <= 0) + { + ActiveEffectsArray[i].NextTickDuration = ActiveEffectsArray[i].GameplayEffectInstance->TickPeriod; + Internal_ApplyGameplayStatEffect(ActiveEffectsArray[i].GameplayEffectInstance); + } + } + } + + for (const FName StatName : StatNames) + { + const float OldValue = GetStatValue(StatName); + RecalculateStatCurrentValue(StatName, OldValue); + } + +#if WITH_EDITOR + for (const auto& Stat : StatArray) + { + StatValuesPreview.Add(Stat.StatDef.StatName, GetStatValue(Stat.StatDef.StatName)); + } +#endif +} + +bool UStatForgeComponent::CanApplyEffect(const UGameplayStatEffect* StatEffect) const +{ + if (!StatEffect) return false; + + return GetGameplayTags().HasAll(StatEffect->TagsRequiredToApply); +} + +bool UStatForgeComponent::CanTickEffect(const UGameplayStatEffect* StatEffect) const +{ + if (!StatEffect) return false; + + return GetGameplayTags().HasAll(StatEffect->TagsRequiredToTick); +} + + diff --git a/Plugins/StatForge/Source/StatForge/Private/Config/StatForgeDeveloperSettings.cpp b/Plugins/StatForge/Source/StatForge/Private/Config/StatForgeDeveloperSettings.cpp new file mode 100644 index 00000000..13e78599 --- /dev/null +++ b/Plugins/StatForge/Source/StatForge/Private/Config/StatForgeDeveloperSettings.cpp @@ -0,0 +1,100 @@ +// Fill out your copyright notice in the Description page of Project Settings. + +#include "Config/StatForgeDeveloperSettings.h" +#include "Data/StatForgeRegistry.h" + +#if WITH_EDITOR +void UStatForgeDeveloperSettings::PostEditChangeProperty(struct FPropertyChangedEvent& PropertyChangedEvent) +{ + Super::PostEditChangeProperty(PropertyChangedEvent); + + if (PropertyChangedEvent.GetPropertyName() == GET_MEMBER_NAME_CHECKED(UStatForgeDeveloperSettings, StatMap)) + { + TSet Seen; + for (int32 i = StatMap.Num() - 1; i >= 0; i--) + { + if (StatMap[i].StatName.IsNone()) + { + continue; + } + + if (Seen.Contains(StatMap[i].StatName)) + { + FMessageDialog::Open(EAppMsgType::Ok, + FText::Format(INVTEXT("StatForge: Duplicate stat name '{0}' removed."),FText::FromName(StatMap[i].StatName))); + StatMap.RemoveAt(i); + } + else + { + Seen.Add(StatMap[i].StatName); + } + } + } +} + +void UStatForgeDeveloperSettings::PostReloadConfig(class FProperty* PropertyThatWasLoaded) +{ + Super::PostReloadConfig(PropertyThatWasLoaded); + MergeRegistryIntoStatMap(); +} +#endif + +FStatDef UStatForgeDeveloperSettings::GetStatByName(FName StatName) const +{ + for (const FStatDef Def : StatMap) + { + if (Def.StatName == StatName) + { + return Def; + } + } + + return FStatDef(); +} + +TArray UStatForgeDeveloperSettings::GetStatNames() +{ + TArray Names; + + if (const UStatForgeDeveloperSettings* Settings = GetDefault()) + { + for (const FStatDef Def : Settings->StatMap) + { + Names.Add(Def.StatName); + } + } + + return Names; +} + +const FStatDef* UStatForgeDeveloperSettings::FindStat(const FName& StatName) const +{ + return StatMap.FindByPredicate([&](const FStatDef& Def) + { + return Def.StatName == StatName; + }); +} + +void UStatForgeDeveloperSettings::PostInitProperties() +{ + Super::PostInitProperties(); + MergeRegistryIntoStatMap(); +} + +void UStatForgeDeveloperSettings::MergeRegistryIntoStatMap() +{ + UE_LOG(LogTemp, Warning, TEXT("StatForge: Merging, registry has %d stats"), FStatForgeRegistry::Get().Num()); + + for (const FStatDef& Registered : FStatForgeRegistry::Get()) + { + const bool bExists = StatMap.ContainsByPredicate([&](const FStatDef& Def) + { + return Def.StatName == Registered.StatName; + }); + + if (!bExists) + { + StatMap.Add(Registered); + } + } +} diff --git a/Plugins/StatForge/Source/StatForge/Private/Data/GameplayStatEffect.cpp b/Plugins/StatForge/Source/StatForge/Private/Data/GameplayStatEffect.cpp new file mode 100644 index 00000000..5494961d --- /dev/null +++ b/Plugins/StatForge/Source/StatForge/Private/Data/GameplayStatEffect.cpp @@ -0,0 +1,28 @@ +// Fill out your copyright notice in the Description page of Project Settings. + + +#include "Data/GameplayStatEffect.h" +#include "Components/StatForgeComponent.h" + +float FGameplayStatEffectMod::GetModValue(UStatForgeComponent* StatComponent, const TMap& DataValueMap) const +{ + switch (CalculationType) { + case EGameplayStatEffectModCalculation::Flat: + return FlatValue; + case EGameplayStatEffectModCalculation::CalculationClass: + { + if (!StatCalculationClass) return 0.f; + + UStatEffectModCalculation* Calc = NewObject(StatComponent, StatCalculationClass); + return Calc->GetValue(StatComponent); + } + case EGameplayStatEffectModCalculation::DataValue: + { + if (const float* Value = DataValueMap.Find(DataValueName)) + { + return *Value; + } + } + } + return 0.f; +} diff --git a/Plugins/StatForge/Source/StatForge/Private/Data/StatForgeDefs.cpp b/Plugins/StatForge/Source/StatForge/Private/Data/StatForgeDefs.cpp new file mode 100644 index 00000000..22593bce --- /dev/null +++ b/Plugins/StatForge/Source/StatForge/Private/Data/StatForgeDefs.cpp @@ -0,0 +1,4 @@ + +#include "Data/StatForgeDefs.h" + + diff --git a/Plugins/StatForge/Source/StatForge/Private/Helpers/StatEffectModCalculation.cpp b/Plugins/StatForge/Source/StatForge/Private/Helpers/StatEffectModCalculation.cpp new file mode 100644 index 00000000..02cc6702 --- /dev/null +++ b/Plugins/StatForge/Source/StatForge/Private/Helpers/StatEffectModCalculation.cpp @@ -0,0 +1,4 @@ +// Fill out your copyright notice in the Description page of Project Settings. + + +#include "Helpers/StatEffectModCalculation.h" diff --git a/Plugins/StatForge/Source/StatForge/Private/Helpers/StatForgeFunctionLibrary.cpp b/Plugins/StatForge/Source/StatForge/Private/Helpers/StatForgeFunctionLibrary.cpp new file mode 100644 index 00000000..1c39a074 --- /dev/null +++ b/Plugins/StatForge/Source/StatForge/Private/Helpers/StatForgeFunctionLibrary.cpp @@ -0,0 +1,27 @@ +// Fill out your copyright notice in the Description page of Project Settings. + + +#include "Helpers/StatForgeFunctionLibrary.h" + +#include "Components/StatForgeComponent.h" + +float UStatForgeFunctionLibrary::GetStatValue(const FName StatName, AActor* Actor) +{ + if (!Actor || StatName.IsNone()) return 0.f; + + UStatForgeComponent* StatForgeComponent = Actor->GetComponentByClass(); + if(!StatForgeComponent) return 0.f; + + return StatForgeComponent->GetStatValue(StatName); +} + +FGuid UStatForgeFunctionLibrary::ApplyGameplayStatEffect(const TSubclassOf EffectClass, + TMap DataValueMap, AActor* Actor, int32 Stacks) +{ + if (!EffectClass || !Actor) return FGuid(); + + UStatForgeComponent* StatForgeComponent = Actor->GetComponentByClass(); + if(!StatForgeComponent) return FGuid(); + + return StatForgeComponent->ApplyGameplayStatEffect(EffectClass, DataValueMap, Stacks); +} diff --git a/Plugins/StatForge/Source/StatForge/Private/StatForge.cpp b/Plugins/StatForge/Source/StatForge/Private/StatForge.cpp new file mode 100644 index 00000000..be2e7afd --- /dev/null +++ b/Plugins/StatForge/Source/StatForge/Private/StatForge.cpp @@ -0,0 +1,26 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#include "StatForge.h" + +#include "Config/StatForgeDeveloperSettings.h" + +#define LOCTEXT_NAMESPACE "FStatForgeModule" + +void FStatForgeModule::StartupModule() +{ + // This code will execute after your module is loaded into memory; the exact timing is specified in the .uplugin file per-module + if (UStatForgeDeveloperSettings* Settings = GetMutableDefault()) + { + Settings->EnsureRegistryMerged(); + } +} + +void FStatForgeModule::ShutdownModule() +{ + // This function may be called during shutdown to clean up your module. For modules that support dynamic reloading, + // we call this function before unloading the module. +} + +#undef LOCTEXT_NAMESPACE + +IMPLEMENT_MODULE(FStatForgeModule, StatForge) \ No newline at end of file diff --git a/Plugins/StatForge/Source/StatForge/Public/Components/StatForgeComponent.h b/Plugins/StatForge/Source/StatForge/Public/Components/StatForgeComponent.h new file mode 100644 index 00000000..5dd09bce --- /dev/null +++ b/Plugins/StatForge/Source/StatForge/Public/Components/StatForgeComponent.h @@ -0,0 +1,139 @@ +// Fill out your copyright notice in the Description page of Project Settings. + +#pragma once + +#include "CoreMinimal.h" +#include "Components/ActorComponent.h" +#include "Data/GameplayStatEffect.h" +#include "Data/StatForgeDefs.h" +#include "StatForgeComponent.generated.h" + +struct FGameplayStatEffectMod; + +USTRUCT() +struct FActiveGameplayStatEffect +{ + GENERATED_BODY() + + UPROPERTY() + FGuid Handle; + + UPROPERTY() + TSubclassOf GameplayEffectClass; + + UPROPERTY() + TObjectPtr GameplayEffectInstance; + + UPROPERTY() + TObjectPtr SourceObject = nullptr; + + UPROPERTY() + int32 StackCount = 1; + + UPROPERTY() + float RemainingDuration = -1.f; + + UPROPERTY() + float NextTickDuration = -1.f; + + UPROPERTY() + bool bIsTicking = false; + + static FActiveGameplayStatEffect CreateNew(UObject* SourceObject, UGameplayStatEffect* Effect, int32 Stacks = 1); +}; + +UENUM() +enum class EIsStatResult : uint8 +{ + Yes, + No +}; + +DECLARE_DYNAMIC_MULTICAST_DELEGATE_ThreeParams(FOnStatChanged, FName, StatName, float, OldValue, float, NewValue); +DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnGameplayTagsChangedDelegate); + +UCLASS(Blueprintable, BlueprintType, ClassGroup=(StatForge), meta=(BlueprintSpawnableComponent)) +class STATFORGE_API UStatForgeComponent : public UActorComponent +{ + GENERATED_BODY() + +public: + UStatForgeComponent(); + virtual void BeginPlay() override; + virtual void TickComponent(float DeltaTime, enum ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) override; + + UFUNCTION(BlueprintCallable, Category = "Stat Forge", meta = (ExpandEnumAsExecs = "Result")) + static void IsStat(FName StatName, UPARAM(meta = (GetOptions = "StatForgeDeveloperSettings.GetStatNames")) FName CheckedName, EIsStatResult& Result); + + UFUNCTION(BlueprintPure, Category = "Stat Forge") + float GetStatValue(UPARAM(meta = (GetOptions = "StatForgeDeveloperSettings.GetStatNames")) const FName StatName) const; + + UFUNCTION(BlueprintCallable, Category = "Stat Forge", meta = (AutoCreateRefTerm = "DataValueMap")) + FGuid ApplyGameplayStatEffect(const TSubclassOf EffectClass, TMap DataValueMap, int32 Stacks = 1); + + UFUNCTION(BlueprintCallable, Category = "Stat Forge") + bool RemoveGameplayStatEffect(FGuid EffectId); + + UFUNCTION(BlueprintPure, Category = "Stat Forge") + bool HasStat(const FName& StatName) const; + + UPROPERTY(EditDefaultsOnly, BlueprintReadWrite, Category = "Stat Forge|Stats", meta = (GetOptions = "StatForgeDeveloperSettings.GetStatNames")) + TMap InitialStatValueArray; + + UPROPERTY(EditDefaultsOnly, BlueprintReadWrite, Category = "Stat Forge|Stats") + TArray> InitialEffectArray; + + UPROPERTY(BlueprintAssignable, Category = "Stat Forge|Events") + FOnStatChanged OnStatChanged; + + /** Begin TAGS */ + + UPROPERTY(BlueprintAssignable, Category = Buffs) + FOnGameplayTagsChangedDelegate OnTagsChanged; + + UFUNCTION(BlueprintCallable, Category = "Stat Forge|Tags") + void AddGameplayTag(const FGameplayTag& NewTag); + + UFUNCTION(BlueprintCallable, Category = "Stat Forge|Tags") + void AddGameplayTags(const FGameplayTagContainer& NewTags); + + UFUNCTION(BlueprintCallable, Category = "Stat Forge|Tags") + bool RemoveGameplayTag(const FGameplayTag& NewTag); + + UFUNCTION(BlueprintCallable, Category = "Stat Forge|Tags") + void RemoveGameplayTags(const FGameplayTagContainer& NewTags); + + const FGameplayTagContainer& GetGameplayTags() const { return CharacterGameplayTags; } + +protected: + + void NotifyGameplayTagsChanged() const; + /** End TAGS */ + + virtual void PreGameplayEffectExecute(UGameplayStatEffect* Effect) {} + virtual void PostGameplayEffectExecute(UGameplayStatEffect* Effect) {} + virtual void PreStatModify(const FName StatName) {} + virtual void PostStatModify(const FName StatName) {} + void Internal_ApplyGameplayStatEffect(UGameplayStatEffect* Effect); + virtual void ApplyModToStat(const FGameplayStatEffectMod& Mod, const TMap DataValueMap); + virtual void RecalculateStatCurrentValue(const FName StatName, const float OldValue); + static TArray GetAffectedStatsFromEffect(UGameplayStatEffect* Effect); + FStatRuntimeDef* GetStatRef(const FName StatName); + void TickEffects(float DeltaTime); + bool CanApplyEffect(const UGameplayStatEffect* StatEffect) const; + bool CanTickEffect(const UGameplayStatEffect* StatEffect) const; + + UPROPERTY() + TArray StatArray; + +#if WITH_EDITORONLY_DATA + UPROPERTY(VisibleAnywhere, BlueprintReadOnly) + TMap StatValuesPreview; +#endif + + UPROPERTY() + TArray ActiveEffectsArray; + + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Stat Forge|Tags") + FGameplayTagContainer CharacterGameplayTags; +}; diff --git a/Plugins/StatForge/Source/StatForge/Public/Config/StatForgeDeveloperSettings.h b/Plugins/StatForge/Source/StatForge/Public/Config/StatForgeDeveloperSettings.h new file mode 100644 index 00000000..fcfde17e --- /dev/null +++ b/Plugins/StatForge/Source/StatForge/Public/Config/StatForgeDeveloperSettings.h @@ -0,0 +1,40 @@ +// Fill out your copyright notice in the Description page of Project Settings. + +#pragma once + +#include "CoreMinimal.h" +#include "Data/StatForgeDefs.h" +#include "Engine/DeveloperSettings.h" +#include "StatForgeDeveloperSettings.generated.h" + +/** + * + */ +UCLASS(Config = Game, defaultconfig, meta = (DisplayName = "Stat Forge Settings")) +class STATFORGE_API UStatForgeDeveloperSettings : public UDeveloperSettings +{ + GENERATED_BODY() +public: + UPROPERTY(Config, EditAnywhere, BlueprintReadWrite, Category = "Setup") + TArray StatMap; + +#if WITH_EDITOR + virtual void PostEditChangeProperty(struct FPropertyChangedEvent& PropertyChangedEvent) override; + virtual void PostReloadConfig(class FProperty* PropertyThatWasLoaded) override; +#endif + + FStatDef GetStatByName(FName StatName) const; + + UFUNCTION() + static TArray GetStatNames(); + const FStatDef* FindStat(const FName& StatName) const; + void EnsureRegistryMerged() { MergeRegistryIntoStatMap(); } + +protected: + + virtual void PostInitProperties() override; + +private: + void MergeRegistryIntoStatMap(); + +}; diff --git a/Plugins/StatForge/Source/StatForge/Public/Data/GameplayStatEffect.h b/Plugins/StatForge/Source/StatForge/Public/Data/GameplayStatEffect.h new file mode 100644 index 00000000..be80c248 --- /dev/null +++ b/Plugins/StatForge/Source/StatForge/Public/Data/GameplayStatEffect.h @@ -0,0 +1,96 @@ +// Fill out your copyright notice in the Description page of Project Settings. + +#pragma once + +#include "CoreMinimal.h" +#include "StatForgeDefs.h" +#include "UObject/Object.h" +#include "GameplayStatEffect.generated.h" + +class UGameplayStatEffect; + +UENUM(BlueprintType) +enum class EModOperation : uint8 +{ + Add, + MultiplyAdditive UMETA(DisplayName = "Multiply Additive"), + MultiplyCompound UMETA(DisplayName = "Multiply Compound"), + Override +}; + +static constexpr float GameplayStatEffectDuration_Instant = 0.f; + +UENUM(BlueprintType) +enum class EGameplayStatEffectModCalculation : uint8 +{ + Flat, + CalculationClass, + DataValue +}; + +USTRUCT(BLueprintType) +struct STATFORGE_API FGameplayStatEffectMod +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Stat Forge|Stats", meta = (GetOptions = "StatForgeDeveloperSettings.GetStatNames")) + FName AffectedStat; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Stat Forge|Stats") + EModOperation ModOperation; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Stat Forge|Stats") + EGameplayStatEffectModCalculation CalculationType; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Stat Forge|Stats", meta = (EditCondition = "CalculationType==EGameplayStatEffectModCalculation::Flat", EditConditionHides)) + float FlatValue = 0.f; + + UPROPERTY(EditAnywhere, Category = "Stat Forge|Stats", meta = (EditCondition = "CalculationType==EGameplayStatEffectModCalculation::CalculationClass", EditConditionHides, BlueprintBaseOnly, ShowTreeView)) + TSubclassOf StatCalculationClass; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Stat Forge|Stats", meta = (EditCondition = "CalculationType==EGameplayStatEffectModCalculation::DataValue", EditConditionHides)) + FName DataValueName; + + float GetModValue(UStatForgeComponent* StatComponent, const TMap& DataValueMap) const; +}; + +/** + * + */ +UCLASS(Blueprintable) +class STATFORGE_API UGameplayStatEffect : public UObject +{ + GENERATED_BODY() +public: + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Duration") + float Duration = 0.f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Duration") + float TickPeriod = 0.f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Modifiers") + TArray Modifiers; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Tags") + FGameplayTagContainer EffectTags; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Tags") + FGameplayTagContainer CharacterTags; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Tags|Requirements", meta = (EditCondition = "Duration==0")) + FGameplayTagContainer TagsRequiredToApply; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Tags|Requirements", meta = (EditCondition = "Duration!=0")) + FGameplayTagContainer TagsRequiredToTick; + + UFUNCTION(BlueprintCallable, Category = "Stat Forge|Effect") + void AppendDataValueMap(TMap InDataValueMap) { DataValueMap.Append(InDataValueMap); } + + const TMap& GetDataValueMap() { return DataValueMap; } + +protected: + + UPROPERTY() + TMap DataValueMap; +}; diff --git a/Plugins/StatForge/Source/StatForge/Public/Data/StatDefinitionsExample.h b/Plugins/StatForge/Source/StatForge/Public/Data/StatDefinitionsExample.h new file mode 100644 index 00000000..656ee444 --- /dev/null +++ b/Plugins/StatForge/Source/StatForge/Public/Data/StatDefinitionsExample.h @@ -0,0 +1,11 @@ + +#pragma once + +#include "StatForgeRegistry.h" + +// -Flat Stats- +// MAKE_STAT(Health) + +// -Grouped Stats- +// MAKE_STAT_NS(Combat, AttackSpeed) +// MAKE_STAT_NS(Locomotion, MovementSpeed) diff --git a/Plugins/StatForge/Source/StatForge/Public/Data/StatForgeDefs.h b/Plugins/StatForge/Source/StatForge/Public/Data/StatForgeDefs.h new file mode 100644 index 00000000..366b35db --- /dev/null +++ b/Plugins/StatForge/Source/StatForge/Public/Data/StatForgeDefs.h @@ -0,0 +1,72 @@ + +#pragma once + +#include "CoreMinimal.h" +#include "GameplayTagContainer.h" +#include "Helpers/StatEffectModCalculation.h" +#include "StatForgeDefs.generated.h" + + +UENUM(BlueprintType) +enum class EStatDefClampMode : uint8 +{ + None, + FlatValue, + Stat +}; + +USTRUCT(BlueprintType) +struct STATFORGE_API FStatDefClampMode +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Stat Forge|Stats") + EStatDefClampMode ClampMode = EStatDefClampMode::None; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, DisplayName = "Value", Category = "Stat Forge|Stats", meta = (EditCondition = "ClampMode==EStatDefClampMode::FlatValue", EditConditionHides)) + float ClampModeFlatValue = 0.f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, DisplayName = "Stat Reference", Category = "Stat Forge|Stats", meta = (EditCondition = "ClampMode==EStatDefClampMode::Stat", EditConditionHides, GetOptions = "StatForgeDeveloperSettings.GetStatNames")) + FName ClampModeStatValue; +}; + +USTRUCT(BlueprintType) +struct STATFORGE_API FStatDef +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Stat Forge|Stats") + FName StatName; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Stat Forge|Stats") + FText StatDescriptiveName; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Stat Forge|Stats") + FStatDefClampMode ClampMode; + + bool IsValid() const { return !StatName.IsNone(); } +}; + +USTRUCT(BlueprintType) +struct STATFORGE_API FStatRuntimeDef +{ + GENERATED_BODY() + + FStatRuntimeDef() { StatDef = FStatDef(); } + explicit FStatRuntimeDef(const FStatDef& Def) + { + StatDef = Def; + } + + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Stat Forge|Stats") + FStatDef StatDef; + + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Stat Forge|Stats") + float BaseValue = 0.f; + + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Stat Forge|Stats") + float CurrentValue = 0.f; + + float GetBaseValue() const { return BaseValue; } + float GetCurrentValue() const { return CurrentValue; } +}; diff --git a/Plugins/StatForge/Source/StatForge/Public/Data/StatForgeRegistry.h b/Plugins/StatForge/Source/StatForge/Public/Data/StatForgeRegistry.h new file mode 100644 index 00000000..e77c5f3b --- /dev/null +++ b/Plugins/StatForge/Source/StatForge/Public/Data/StatForgeRegistry.h @@ -0,0 +1,81 @@ + +#pragma once + +#include "CoreMinimal.h" +#include "StatForgeDefs.h" + +class STATFORGE_API FStatForgeRegistry +{ +public: + + static TArray& Get() + { + static TArray Registry; + return Registry; + } + + static void RegisterStat(const FName& StatName) + { + UE_LOG(LogTemp, Warning, TEXT("StatForge: Registering %s"), *StatName.ToString()); + + const bool bAlreadyRegistered = Get().ContainsByPredicate([&](const FStatDef& Def) + { + return Def.StatName == StatName; + }); + + if (!bAlreadyRegistered) + { + FStatDef StatDef; + StatDef.StatName = StatName; + Get().Add(StatDef); + } + } +}; + +#define STATFORGE_DECLARE_STAT(StatName)\ + namespace FStat\ + {\ + extern const FName StatName;\ + }\ + +#define STATFORGE_DECLARE_STAT_NS(Group, StatName)\ + namespace FStat\ + {\ + namespace Group\ + {\ + extern const FName StatName;\ + }\ + }\ + + +#define STATFORGE_MAKE_STAT(StatName)\ + namespace FStat\ + {\ + const FName StatName = FName(#StatName);\ + namespace Detail\ + {\ + struct FAutoRegister_##StatName\ + {\ + FAutoRegister_##StatName() { FStatForgeRegistry::RegisterStat(FName(#StatName)); }\ + };\ + FAutoRegister_##StatName AutoRegister_##StatName;\ + }\ + }\ + +#define STATFORGE_MAKE_STAT_NS(Group, StatName)\ + namespace FStat\ + {\ + namespace Group\ + {\ + const FName StatName = FName(#Group "." #StatName);\ + namespace Detail\ + {\ + struct FAutoRegister_##Group##_##StatName\ + {\ + FAutoRegister_##Group##_##StatName()\ + { FStatForgeRegistry::RegisterStat(FName(#Group "." #StatName)); }\ + };\ + FAutoRegister_##Group##_##StatName AutoRegister_##Group##_##StatName;\ + }\ + }\ + }\ diff --git a/Plugins/StatForge/Source/StatForge/Public/Helpers/StatEffectModCalculation.h b/Plugins/StatForge/Source/StatForge/Public/Helpers/StatEffectModCalculation.h new file mode 100644 index 00000000..0333baee --- /dev/null +++ b/Plugins/StatForge/Source/StatForge/Public/Helpers/StatEffectModCalculation.h @@ -0,0 +1,22 @@ +// Fill out your copyright notice in the Description page of Project Settings. + +#pragma once + +#include "CoreMinimal.h" +#include "UObject/Object.h" +#include "StatEffectModCalculation.generated.h" + +class UStatForgeComponent; + +/** + * + */ +UCLASS(Blueprintable) +class STATFORGE_API UStatEffectModCalculation : public UObject +{ + GENERATED_BODY() +public: + UFUNCTION(BlueprintNativeEvent, Category = "Stat Forge") + float GetValue(UStatForgeComponent* StatForgeComponent); + virtual float GetValue_Implementation(UStatForgeComponent* StatForgeComponent) { return 0.f; } +}; diff --git a/Plugins/StatForge/Source/StatForge/Public/Helpers/StatForgeFunctionLibrary.h b/Plugins/StatForge/Source/StatForge/Public/Helpers/StatForgeFunctionLibrary.h new file mode 100644 index 00000000..5657a594 --- /dev/null +++ b/Plugins/StatForge/Source/StatForge/Public/Helpers/StatForgeFunctionLibrary.h @@ -0,0 +1,24 @@ +// Fill out your copyright notice in the Description page of Project Settings. + +#pragma once + +#include "CoreMinimal.h" +#include "Kismet/BlueprintFunctionLibrary.h" +#include "StatForgeFunctionLibrary.generated.h" + +class UGameplayStatEffect; +/** + * + */ +UCLASS() +class STATFORGE_API UStatForgeFunctionLibrary : public UBlueprintFunctionLibrary +{ + GENERATED_BODY() + + UFUNCTION(BlueprintPure, Category = "Stat Forge") + static float GetStatValue(UPARAM(meta = (GetOptions = "StatForgeDeveloperSettings.GetStatNames")) const FName StatName, AActor* Actor); + + UFUNCTION(BlueprintCallable, Category = "Stat Forge", meta = (AutoCreateRefTerm = "DataValueMap")) + static FGuid ApplyGameplayStatEffect(const TSubclassOf EffectClass, TMap DataValueMap, AActor* Actor, int32 Stacks = 1); + +}; diff --git a/Plugins/StatForge/Source/StatForge/Public/StatForge.h b/Plugins/StatForge/Source/StatForge/Public/StatForge.h new file mode 100644 index 00000000..323dd728 --- /dev/null +++ b/Plugins/StatForge/Source/StatForge/Public/StatForge.h @@ -0,0 +1,14 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#pragma once + +#include "Modules/ModuleManager.h" + +class FStatForgeModule : public IModuleInterface +{ +public: + + /** IModuleInterface implementation */ + virtual void StartupModule() override; + virtual void ShutdownModule() override; +}; diff --git a/Plugins/StatForge/Source/StatForge/StatForge.Build.cs b/Plugins/StatForge/Source/StatForge/StatForge.Build.cs new file mode 100644 index 00000000..ba43f24f --- /dev/null +++ b/Plugins/StatForge/Source/StatForge/StatForge.Build.cs @@ -0,0 +1,54 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +using UnrealBuildTool; + +public class StatForge : ModuleRules +{ + public StatForge(ReadOnlyTargetRules Target) : base(Target) + { + PCHUsage = ModuleRules.PCHUsageMode.UseExplicitOrSharedPCHs; + + PublicIncludePaths.AddRange( + new string[] { + // ... add public include paths required here ... + } + ); + + + PrivateIncludePaths.AddRange( + new string[] { + // ... add other private include paths required here ... + } + ); + + + PublicDependencyModuleNames.AddRange( + new string[] + { + "Core", + "GameplayTags", + "DeveloperSettings" + } + ); + + + PrivateDependencyModuleNames.AddRange( + new string[] + { + "CoreUObject", + "Engine", + "Slate", + "SlateCore", + // ... add private dependencies that you statically link with here ... + } + ); + + + DynamicallyLoadedModuleNames.AddRange( + new string[] + { + // ... add any modules that your module loads dynamically here ... + } + ); + } +} diff --git a/Plugins/StatForge/StatForge.uplugin b/Plugins/StatForge/StatForge.uplugin new file mode 100644 index 00000000..d9501754 --- /dev/null +++ b/Plugins/StatForge/StatForge.uplugin @@ -0,0 +1,24 @@ +{ + "FileVersion": 3, + "Version": 1, + "VersionName": "1.0", + "FriendlyName": "StatForge", + "Description": "", + "Category": "Gameplay", + "CreatedBy": "", + "CreatedByURL": "", + "DocsURL": "", + "MarketplaceURL": "", + "SupportURL": "", + "CanContainContent": true, + "IsBetaVersion": false, + "IsExperimentalVersion": false, + "Installed": false, + "Modules": [ + { + "Name": "StatForge", + "Type": "Runtime", + "LoadingPhase": "Default" + } + ] +} \ No newline at end of file diff --git a/Plugins/StevesUEHelpers/Config/FilterPlugin.ini b/Plugins/StevesUEHelpers/Config/FilterPlugin.ini new file mode 100644 index 00000000..ccebca2f --- /dev/null +++ b/Plugins/StevesUEHelpers/Config/FilterPlugin.ini @@ -0,0 +1,8 @@ +[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 diff --git a/Plugins/StevesUEHelpers/License.txt b/Plugins/StevesUEHelpers/License.txt new file mode 100644 index 00000000..9a2ca36d --- /dev/null +++ b/Plugins/StevesUEHelpers/License.txt @@ -0,0 +1,8 @@ +The MIT License (MIT) +Copyright © 2020 Steve Streeting + +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. \ No newline at end of file diff --git a/Plugins/StevesUEHelpers/Resources/HOptionDemo.gif b/Plugins/StevesUEHelpers/Resources/HOptionDemo.gif new file mode 100644 index 00000000..7ab497d0 Binary files /dev/null and b/Plugins/StevesUEHelpers/Resources/HOptionDemo.gif differ diff --git a/Plugins/StevesUEHelpers/Resources/Icon.afphoto b/Plugins/StevesUEHelpers/Resources/Icon.afphoto new file mode 100644 index 00000000..70185096 Binary files /dev/null and b/Plugins/StevesUEHelpers/Resources/Icon.afphoto differ diff --git a/Plugins/StevesUEHelpers/Resources/Icon128.png b/Plugins/StevesUEHelpers/Resources/Icon128.png new file mode 100644 index 00000000..a3f999e6 --- /dev/null +++ b/Plugins/StevesUEHelpers/Resources/Icon128.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:143db8e6ebc1960a3118dfb9f3155f8633ed96b2b5b26b87f329d3805f44373a +size 5284 diff --git a/Plugins/StevesUEHelpers/Resources/RichTextInputImageExample.gif b/Plugins/StevesUEHelpers/Resources/RichTextInputImageExample.gif new file mode 100644 index 00000000..a8a765d9 Binary files /dev/null and b/Plugins/StevesUEHelpers/Resources/RichTextInputImageExample.gif differ diff --git a/Plugins/StevesUEHelpers/Resources/UInputImageDemo2.gif b/Plugins/StevesUEHelpers/Resources/UInputImageDemo2.gif new file mode 100644 index 00000000..e42ef2c5 Binary files /dev/null and b/Plugins/StevesUEHelpers/Resources/UInputImageDemo2.gif differ diff --git a/Plugins/StevesUEHelpers/Resources/gameinstance.png b/Plugins/StevesUEHelpers/Resources/gameinstance.png new file mode 100644 index 00000000..2e662407 --- /dev/null +++ b/Plugins/StevesUEHelpers/Resources/gameinstance.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bb2b11caa1c246d4a2f374c914c4932a2f7b79cdf656a45c9afc07ea7d40c86f +size 38966 diff --git a/Plugins/StevesUEHelpers/Resources/inputchangeevent.png b/Plugins/StevesUEHelpers/Resources/inputchangeevent.png new file mode 100644 index 00000000..a03e0efd --- /dev/null +++ b/Plugins/StevesUEHelpers/Resources/inputchangeevent.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4586592b330031d1fb56fddce5f8cb7f77227c8055a20114a9770536e1c59d96 +size 86237 diff --git a/Plugins/StevesUEHelpers/Resources/lastinput.png b/Plugins/StevesUEHelpers/Resources/lastinput.png new file mode 100644 index 00000000..b8fb0d90 --- /dev/null +++ b/Plugins/StevesUEHelpers/Resources/lastinput.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:25ff64969784d32713bbf0570bedca0135c93e5678c353552506a4af0ffff64d +size 45596 diff --git a/Plugins/StevesUEHelpers/Resources/menupushbyclass.png b/Plugins/StevesUEHelpers/Resources/menupushbyclass.png new file mode 100644 index 00000000..bdc7dd87 --- /dev/null +++ b/Plugins/StevesUEHelpers/Resources/menupushbyclass.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:728bc91bfa7d35c495e5497a57e6d5face5fe822a8d01967b48f184718f68dd7 +size 91649 diff --git a/Plugins/StevesUEHelpers/Resources/optioncustomnav.png b/Plugins/StevesUEHelpers/Resources/optioncustomnav.png new file mode 100644 index 00000000..40cf78ef --- /dev/null +++ b/Plugins/StevesUEHelpers/Resources/optioncustomnav.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:da51a82dee04a3f1f993d6406c3329e265ceefe96921e12a0c24893f376c7e6f +size 95226 diff --git a/Plugins/StevesUEHelpers/Resources/popmenu.png b/Plugins/StevesUEHelpers/Resources/popmenu.png new file mode 100644 index 00000000..9450d48f --- /dev/null +++ b/Plugins/StevesUEHelpers/Resources/popmenu.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:77794196b4ca06dcf9830bce1fc8770e04f13da4b2a5474577eb4aa18f80bad6 +size 48325 diff --git a/Plugins/StevesUEHelpers/Resources/richtextdecoratorconfig.png b/Plugins/StevesUEHelpers/Resources/richtextdecoratorconfig.png new file mode 100644 index 00000000..27fecd67 --- /dev/null +++ b/Plugins/StevesUEHelpers/Resources/richtextdecoratorconfig.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:71e7cf60e8dbaae1544f8de678aba4e3c244707f47e48bb8f774c2ebfa8da6d6 +size 9514 diff --git a/Plugins/StevesUEHelpers/Resources/showingamenu.png b/Plugins/StevesUEHelpers/Resources/showingamenu.png new file mode 100644 index 00000000..5eadd32e --- /dev/null +++ b/Plugins/StevesUEHelpers/Resources/showingamenu.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:11d342c1e77c8afeb9883ddb2f1134c1d285cfcd477a5595d117e118b7a6f138 +size 270159 diff --git a/Plugins/StevesUEHelpers/Resources/typewriterexample.gif b/Plugins/StevesUEHelpers/Resources/typewriterexample.gif new file mode 100644 index 00000000..8e50c9f6 Binary files /dev/null and b/Plugins/StevesUEHelpers/Resources/typewriterexample.gif differ diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesAssetHelpers.cpp b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesAssetHelpers.cpp new file mode 100644 index 00000000..076c137f --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesAssetHelpers.cpp @@ -0,0 +1,83 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +#include "StevesAssetHelpers.h" + +#include "StevesUEHelpers.h" +#include "Engine/ObjectLibrary.h" +#include "Blueprint/BlueprintSupport.h" + +int UStevesAssetHelpers::FindBlueprintSoftPaths(const TArray& InPaths, + UObjectLibrary* ObjectLibrary, + TArray& OutSoftPaths) +{ + // LoadBlueprintAssetDataFromPaths requires FString array, LoadBlueprintAssetDataFromPath just makes one array per call so no better + // I like using FDirectoryPath for settings though since it enables browsing + TArray StrPaths; + for (auto& Dir : InPaths) + { + StrPaths.Add(Dir.Path); + } + return FindBlueprintSoftPaths(StrPaths, ObjectLibrary, OutSoftPaths); +} + +int UStevesAssetHelpers::FindBlueprintSoftPaths(const TArray& InPaths, + UObjectLibrary* ObjectLibrary, + TArray& OutSoftPaths) +{ + ObjectLibrary->LoadBlueprintAssetDataFromPaths(InPaths); + ObjectLibrary->LoadAssetsFromAssetData(); + // Now they're all loaded, add them + TArray FoundAssets; + ObjectLibrary->GetAssetDataList(FoundAssets); + int Count = 0; + for (auto& Asset : FoundAssets) + { + // Need to resolve BP generated class + const FString GeneratedClassTag = Asset.GetTagValueRef(FBlueprintTags::GeneratedClassPath); + if (GeneratedClassTag.IsEmpty()) + { + UE_LOG(LogStevesUEHelpers, Warning, TEXT("Unable to find GeneratedClass value for asset %s"), *Asset.GetObjectPathString()); + continue; + } + FSoftObjectPath StringRef; + StringRef.SetPath(FPackageName::ExportTextPathToObjectPath(GeneratedClassTag)); + OutSoftPaths.Add(StringRef); + ++Count; + } + + // Don't use OutSoftPaths.Num() in case it wasn't empty to start with + return Count; +} + +int UStevesAssetHelpers::FindBlueprintClasses(const TArray& InPaths, + UObjectLibrary* ObjectLibrary, + TArray& OutClasses) +{ + // LoadBlueprintAssetDataFromPaths requires FString array, LoadBlueprintAssetDataFromPath just makes one array per call so no better + // I like using FDirectoryPath for settings though since it enables browsing + TArray StrPaths; + for (auto& Dir : InPaths) + { + StrPaths.Add(Dir.Path); + } + return FindBlueprintClasses(StrPaths, ObjectLibrary, OutClasses); +} + +int UStevesAssetHelpers::FindBlueprintClasses(const TArray& InPaths, + UObjectLibrary* ObjectLibrary, + TArray& OutClasses) +{ + TArray SoftPaths; + FindBlueprintSoftPaths(InPaths, ObjectLibrary, SoftPaths); + int Count = 0; + for (auto& SoftRef : SoftPaths) + { + if (UClass* TheClass = Cast(SoftRef.ResolveObject())) + { + OutClasses.Add(TheClass); + ++Count; + } + } + return Count; + +} diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesBPL.cpp b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesBPL.cpp new file mode 100644 index 00000000..1756da89 --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesBPL.cpp @@ -0,0 +1,105 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license + + +#include "StevesBPL.h" +#include "Misc/ConfigCacheIni.h" +#include "StevesUiHelpers.h" +#include "StevesUI/StevesUI.h" +#include "ContentStreaming.h" + +void UStevesBPL::SetWidgetFocus(UWidget* Widget) +{ + SetWidgetFocusProperly(Widget); +} + +UPanelSlot* UStevesBPL::InsertChildWidgetAt(UPanelWidget* Parent, UWidget* Child, int AtIndex) +{ + return StevesUiHelpers::InsertChildWidgetAt(Parent, Child, AtIndex); +} + +FStevesBalancedRandomStream UStevesBPL::MakeBalancedRandomStream(int64 Seed) +{ + return FStevesBalancedRandomStream(Seed); +} + +void UStevesBPL::AddViewOriginToStreaming(const FVector& ViewOrigin, + float ScreenWidth, + float FOV, + float BoostFactor, + bool bOverrideLocation, + float Duration, + AActor* ActorToBoost) +{ + IStreamingManager::Get().AddViewInformation(ViewOrigin, + ScreenWidth, + ScreenWidth / FMath::Tan(FMath::DegreesToRadians(FOV * 0.5f)), + BoostFactor, + bOverrideLocation, + Duration, + ActorToBoost); +} + +void UStevesBPL::UpdateStreaming(float DeltaTime, bool bBlockUntilDone) +{ + FStreamingManagerCollection& SM = IStreamingManager::Get(); + SM.UpdateResourceStreaming(DeltaTime, true); + if (bBlockUntilDone) + { + SM.BlockTillAllRequestsFinished(); + } +} + +float UStevesBPL::GetPerceivedLuminance(const FLinearColor& InColour) +{ + // ITU BT.709 + return + InColour.R * 0.2126f + + InColour.G * 0.7152f + + InColour.B * 0.0722f; +} + +float UStevesBPL::GetPerceivedLuminance2(const FLinearColor& InColour) +{ + // ITU BT.601 + return + InColour.R * 0.299f + + InColour.G * 0.587f + + InColour.B * 0.114f; +} + +float UStevesBPL::HeadingAngle2D(const FVector2D& Dir) +{ + const FVector2D NormDir = Dir.GetSafeNormal(); + float Angle = FMath::Acos(NormDir.X); + + if(NormDir.Y < 0.0f) + { + Angle *= -1.0f; + } + + return Angle; +} + +float UStevesBPL::AngleBetween2D(const FVector2D& DirA, const FVector2D& DirB) +{ + const float HeadingA = HeadingAngle2D(DirA); + const float HeadingB = HeadingAngle2D(DirB); + + // Get the shortest route + return FMath::FindDeltaAngleRadians(HeadingA, HeadingB); +} + +FString UStevesBPL::GetProjectVersion() +{ + FString AppVersion; + GConfig->GetString( + TEXT("/Script/EngineSettings.GeneralProjectSettings"), + TEXT("ProjectVersion"), + AppVersion, + GGameIni + ); + + return AppVersion; +} + diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesDebugRenderSceneProxy.cpp b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesDebugRenderSceneProxy.cpp new file mode 100644 index 00000000..f66292c0 --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesDebugRenderSceneProxy.cpp @@ -0,0 +1,108 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license + +#include "StevesDebugRenderSceneProxy.h" +#include "Runtime/Launch/Resources/Version.h" +#include "SceneManagement.h" +#include "DynamicMeshBuilder.h" +#include "Engine/Engine.h" +#include "Materials/Material.h" +#include "Materials/MaterialRenderProxy.h" + +// Added in UE 5.2 +#if ENGINE_MAJOR_VERSION == 5 && ENGINE_MINOR_VERSION >= 2 +#include "Materials/MaterialRenderProxy.h" +#endif + + +void FStevesDebugRenderSceneProxy::GetDynamicMeshElements(const TArray& Views, + const FSceneViewFamily& ViewFamily, uint32 VisibilityMap, FMeshElementCollector& Collector) const +{ + FDebugRenderSceneProxy::GetDynamicMeshElements(Views, ViewFamily, VisibilityMap, Collector); + + for (int32 ViewIndex = 0; ViewIndex < Views.Num(); ViewIndex++) + { + if (VisibilityMap & (1 << ViewIndex)) + { + const FSceneView* View = Views[ViewIndex]; + FPrimitiveDrawInterface* PDI = Collector.GetPDI(ViewIndex); + + // Draw Circles + for (const auto& C : Circles) + { + DrawCircle(PDI, C.Centre, C.X, C.Y, C.Color, C.Radius, C.NumSegments, SDPG_World, C.Thickness, 0, C.Thickness > 0); + } + + // Draw Arcs + for (const auto& C : Arcs) + { + ::DrawArc(PDI, + C.Centre, + C.X, C.Y, + C.MinAngle, C.MaxAngle, + C.Radius, C.NumSegments, + C.Color, SDPG_Foreground); + } + // Draw Cylinders (properly! superclass ignores transforms) + for (const auto& C : CylindersImproved) + { + ::DrawWireCylinder(PDI, + C.Centre, + C.X, + C.Y, + C.Z, + C.Color, + C.Radius, + C.HalfHeight, + C.NumSegments, + SDPG_Foreground); + } + for (const auto& C : CapsulesImproved) + { + ::DrawWireCapsule(PDI, +#if ENGINE_MAJOR_VERSION >= 5 + C.Base, +#else + C.Location, +#endif + C.X, + C.Y, + C.Z, + C.Color, + C.Radius, + C.HalfHeight, + 16, + SDPG_Foreground); + } + + + for (const auto& Mesh : MeshesImproved) + { + FDynamicMeshBuilder MeshBuilder(View->GetFeatureLevel()); + MeshBuilder.AddVertices(Mesh.Vertices); + MeshBuilder.AddTriangles(Mesh.Indices); + + // Parent caches these (only within this function) but let's assume that's not worth it. Will people really + // have lots of meshes with a shared colour in this single context to make it worth it? + const auto MatRenderProxy = new FColoredMaterialRenderProxy(GEngine->WireframeMaterial->GetRenderProxy(), Mesh.Color); + FDynamicMeshBuilderSettings Settings; + Settings.bWireframe = true; + Settings.bUseSelectionOutline = false; + Settings.bUseWireframeSelectionColoring = true; + MeshBuilder.GetMesh(Mesh.LocalToWorld, MatRenderProxy, SDPG_World, Settings, nullptr, ViewIndex, Collector); + } + + } + } +} + +FPrimitiveViewRelevance FStevesDebugRenderSceneProxy::GetViewRelevance(const FSceneView* View) const +{ + // More useful defaults than FDebugRenderSceneProxy + FPrimitiveViewRelevance Result; + Result.bDrawRelevance = IsShown(View); + Result.bDynamicRelevance = true; + Result.bShadowRelevance = false; + Result.bEditorPrimitiveRelevance = UseEditorCompositing(View); + return Result; +} diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesDynamicNavMeshVolume.cpp b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesDynamicNavMeshVolume.cpp new file mode 100644 index 00000000..0b4facaf --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesDynamicNavMeshVolume.cpp @@ -0,0 +1,58 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license + +#include "StevesDynamicNavMeshVolume.h" + +#include "NavigationSystem.h" +#include "Components/BrushComponent.h" +#include "PhysicsEngine/BodySetup.h" + + +AStevesDynamicNavMeshVolume::AStevesDynamicNavMeshVolume() +{ + +} + +void AStevesDynamicNavMeshVolume::SetLocationAndDimensions(const FVector& Location, const FVector& NewDimensions) +{ + SetActorLocation(Location); + UpdateDimensions(NewDimensions); + NotifyNavSystem(); +} + +void AStevesDynamicNavMeshVolume::SetDimensions(const FVector& NewDimensions) +{ + UpdateDimensions(NewDimensions); + NotifyNavSystem(); +} + +void AStevesDynamicNavMeshVolume::UpdateDimensions(const FVector& NewDimensions) +{ + // Volumes are built using UCubeBuilder, but we can't use that class at runtime (Editor only) + // It generates the 6 faces as polys, like old BSP stuff. No idea why for a cube, we don't need that here + // Just box it baby + + if (auto Body = GetBrushComponent()->GetBodySetup()) + { + Body->AggGeom.ConvexElems.Empty(); + if (Body->AggGeom.BoxElems.Num() == 0) + { + Body->AggGeom.BoxElems.Emplace(); + } + auto& Box = Body->AggGeom.BoxElems[0]; + Box.X = NewDimensions.X; + Box.Y = NewDimensions.Y; + Box.Z = NewDimensions.Z; + // Bounds are in World Space, hence use actor location as origin + GetBrushComponent()->Bounds = FBoxSphereBounds(GetActorLocation(), NewDimensions*0.5f, NewDimensions.GetMax()*0.5f); + } + +} + +void AStevesDynamicNavMeshVolume::NotifyNavSystem() +{ + if (UNavigationSystemV1* NavSys = FNavigationSystem::GetCurrent(GetWorld())) + { + NavSys->OnNavigationBoundsUpdated(this); + } +} diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesEasings.cpp b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesEasings.cpp new file mode 100644 index 00000000..36253ae8 --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesEasings.cpp @@ -0,0 +1,128 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +#include "StevesEasings.h" + +float UStevesEasings::EaseAlpha(float InAlpha, EStevesEaseFunction Func) +{ + constexpr float BackC1 = 1.70158f; + constexpr float BackC2 = BackC1 * 1.525f; + constexpr float BackC3 = BackC1 + 1.f; + constexpr float ElasticC4 = UE_TWO_PI / 3.f; + constexpr float ElasticC5 = UE_TWO_PI / 4.5f; + + switch(Func) + { + default: + case EStevesEaseFunction::Linear: + return InAlpha; + case EStevesEaseFunction::EaseIn_Sine: + return 1.f - FMath::Cos(InAlpha * UE_HALF_PI); + case EStevesEaseFunction::EaseOut_Sine: + return FMath::Sin(InAlpha * UE_HALF_PI); + case EStevesEaseFunction::EaseInOut_Sine: + return -(FMath::Cos(UE_PI * InAlpha) - 1.f) / 2.f; + case EStevesEaseFunction::EaseIn_Quad: + return InAlpha*InAlpha; + case EStevesEaseFunction::EaseOut_Quad: + return 1.f - (1.f - InAlpha) * (1.f - InAlpha); + case EStevesEaseFunction::EaseInOut_Quad: + return InAlpha < 0.5f ? 2.f * InAlpha * InAlpha : 1.f - FMath::Pow(-2.f * InAlpha + 2.f, 2.f) / 2.f; + case EStevesEaseFunction::EaseIn_Cubic: + return FMath::Pow(InAlpha, 3); + case EStevesEaseFunction::EaseOut_Cubic: + return 1.f - FMath::Pow(1.f - InAlpha, 3); + case EStevesEaseFunction::EaseInOut_Cubic: + return InAlpha < 0.5f ? 4.f * FMath::Pow(InAlpha, 3) : 1.f - FMath::Pow(-2.f * InAlpha + 2.f, 3) / 2.f; + case EStevesEaseFunction::EaseIn_Quart: + return FMath::Pow(InAlpha, 4); + case EStevesEaseFunction::EaseOut_Quart: + return 1 - FMath::Pow(1.f - InAlpha, 4.f); + case EStevesEaseFunction::EaseInOut_Quart: + return InAlpha < 0.5f ? 8.f * FMath::Pow(InAlpha, 4) : 1.f - FMath::Pow(-2.f * InAlpha + 2.f, 4) / 2.f; + case EStevesEaseFunction::EaseIn_Quint: + return FMath::Pow(InAlpha, 5); + case EStevesEaseFunction::EaseOut_Quint: + return 1 - FMath::Pow(1.f - InAlpha, 5); + case EStevesEaseFunction::EaseInOut_Quint: + return InAlpha < 0.5f ? 16.f * FMath::Pow(InAlpha, 5) : 1.f - FMath::Pow(-2.f * InAlpha + 2.f, 5) / 2.f; + case EStevesEaseFunction::EaseIn_Expo: + return InAlpha <= 0 ? 0 : FMath::Pow(2.f, 10.f * InAlpha - 10.f); + case EStevesEaseFunction::EaseOut_Expo: + return InAlpha >= 1.f ? 1.f : 1.f - FMath::Pow(2.f, -10.f * InAlpha); + case EStevesEaseFunction::EaseInOut_Expo: + if (InAlpha <= 0.f) + return 0; + if (InAlpha >= 1.f) + return 1; + return InAlpha < 0.5f + ? FMath::Pow(2.f, 20.f * InAlpha - 10.f) / 2.f + : (2.f - FMath::Pow(2.f, -20.f * InAlpha + 10.f)) / 2.f; + case EStevesEaseFunction::EaseIn_Circ: + return 1.f - FMath::Sqrt(1.f - FMath::Pow(InAlpha, 2)); + case EStevesEaseFunction::EaseOut_Circ: + return FMath::Sqrt(1.f - FMath::Pow(InAlpha - 1.f, 2)); + case EStevesEaseFunction::EaseInOut_Circ: + return InAlpha < 0.5f + ? (1.f - FMath::Sqrt(1.f - FMath::Pow(2.f * InAlpha, 2))) / 2.f + : (FMath::Sqrt(1.f - FMath::Pow(-2.f * InAlpha + 2.f, 2)) + 1.f) / 2.f; + case EStevesEaseFunction::EaseIn_Back: + return BackC3 * FMath::Pow(InAlpha, 3) - BackC1 * InAlpha * InAlpha; + case EStevesEaseFunction::EaseOut_Back: + return 1.f + BackC3 * FMath::Pow(InAlpha - 1.f, 3) + BackC1 * FMath::Pow(InAlpha - 1.f, 2.f); + case EStevesEaseFunction::EaseInOut_Back: + return InAlpha < 0.5f + ? (FMath::Pow(2.f * InAlpha, 2) * ((BackC2 + 1.f) * 2.f * InAlpha - BackC2)) / 2.f + : (FMath::Pow(2.f * InAlpha - 2.f, 2) * ((BackC2 + 1) * (InAlpha * 2.f - 2.f) + BackC2) + 2.f) / 2.f; + case EStevesEaseFunction::EaseIn_Elastic: + if (InAlpha <= 0.f) + return 0; + + if (InAlpha >= 1.f) + return 1; + return -FMath::Pow(2.f, 10.f * InAlpha - 10.f) * FMath::Sin((InAlpha * 10.f - 10.75f) * ElasticC4); + case EStevesEaseFunction::EaseOut_Elastic: + if (InAlpha <= 0.f) + return 0; + if (InAlpha >= 1.f) + return 1; + return FMath::Pow(2.f, -10.f * InAlpha) * FMath::Sin((InAlpha * 10.f - 0.75f) * ElasticC4) + 1.f; + case EStevesEaseFunction::EaseInOut_Elastic: + if (InAlpha <= 0.f) + return 0; + if (InAlpha >= 1.f) + return 1; + return InAlpha < 0.5f + ? -(FMath::Pow(2.f, 20.f * InAlpha - 10.f) * FMath::Sin((20.f * InAlpha - 11.125f) * ElasticC5)) / + 2.f + : (FMath::Pow(2.f, -20.f * InAlpha + 10.f) * FMath::Sin((20.f * InAlpha - 11.125f) * ElasticC5)) / + 2.f + 1.f; + case EStevesEaseFunction::EaseIn_Bounce: + return 1 - EaseAlpha(1 - InAlpha, EStevesEaseFunction::EaseOut_Bounce); + case EStevesEaseFunction::EaseOut_Bounce: + { + constexpr float n1 = 7.5625f; + constexpr float d1 = 2.75f; + + if (InAlpha < 1.f / d1) { return n1 * InAlpha * InAlpha; } + else if (InAlpha < 2.f / d1) + { + const float newAlpha = InAlpha - 1.5f/d1; + return n1 * newAlpha * newAlpha + 0.75f; + } + else if (InAlpha < 2.5f / d1) + { + const float newAlpha = InAlpha - 2.25f/d1; + return n1 * newAlpha * newAlpha + 0.9375f; + } + else + { + const float newAlpha = InAlpha - 2.625f/d1; + return n1 * newAlpha * newAlpha + 0.984375f; + } + } + case EStevesEaseFunction::EaseInOut_Bounce: + return InAlpha < 0.5f + ? (1.f - EaseAlpha(1.f - 2.f * InAlpha, EStevesEaseFunction::EaseOut_Bounce)) / 2.f + : (1.f + EaseAlpha(2.f * InAlpha - 1.f, EStevesEaseFunction::EaseOut_Bounce)) / 2.f; + } +} diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesEditorVisComponent.cpp b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesEditorVisComponent.cpp new file mode 100644 index 00000000..2cd87356 --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesEditorVisComponent.cpp @@ -0,0 +1,210 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license + + +#include "StevesEditorVisComponent.h" +#include "StaticMeshResources.h" +#include "Engine/StaticMesh.h" +#include "StevesDebugRenderSceneProxy.h" +#include "DynamicMeshBuilder.h" + +UStevesEditorVisComponent::UStevesEditorVisComponent(const FObjectInitializer& ObjectInitializer) + : UPrimitiveComponent(ObjectInitializer) +{ + // set up some constants + PrimaryComponentTick.bCanEverTick = false; + SetCastShadow(false); +#if WITH_EDITORONLY_DATA + // Note: this makes this component invisible on level instances, not sure why + SetIsVisualizationComponent(true); +#endif + SetHiddenInGame(true); + bVisibleInReflectionCaptures = false; + bVisibleInRayTracing = false; + bVisibleInRealTimeSkyCaptures = false; + AlwaysLoadOnClient = false; + bIsEditorOnly = true; + +} + +FPrimitiveSceneProxy* UStevesEditorVisComponent::CreateSceneProxy() +{ + auto Ret = new FStevesDebugRenderSceneProxy(this); + + const FTransform& XForm = GetComponentTransform(); + for (auto& L : Lines) + { + Ret->Lines.Add(FDebugRenderSceneProxy::FDebugLine(XForm.TransformPosition(L.Start), + XForm.TransformPosition(L.End), L.Colour)); + } + for (auto& A : Arrows) + { + Ret->ArrowLines.Add(FDebugRenderSceneProxy::FArrowLine(XForm.TransformPosition(A.Start), + XForm.TransformPosition(A.End), A.Colour)); + } + for (auto& C : Circles) + { + FQuat WorldRot = XForm.TransformRotation(C.Rotation.Quaternion()); + Ret->Circles.Add(FStevesDebugRenderSceneProxy::FDebugCircle( + XForm.TransformPosition(C.Location), + WorldRot.GetForwardVector(), WorldRot.GetRightVector(), + XForm.GetMaximumAxisScale() * C.Radius, + C.NumSegments, C.Colour + )); + } + for (auto& Arc : Arcs) + { + FQuat WorldRot = XForm.TransformRotation(Arc.Rotation.Quaternion()); + Ret->Arcs.Add(FStevesDebugRenderSceneProxy::FDebugArc( + XForm.TransformPosition(Arc.Location), + WorldRot.GetForwardVector(), WorldRot.GetRightVector(), + Arc.MinAngle, Arc.MaxAngle, + XForm.GetMaximumAxisScale() * Arc.Radius, + Arc.NumSegments, Arc.Colour + )); + } + for (auto& S : Spheres) + { + Ret->Spheres.Add(FStevesDebugRenderSceneProxy::FSphere( + XForm.GetMaximumAxisScale() * S.Radius, + XForm.TransformPosition(S.Location), + S.Colour + )); + } + for (auto& Box : Boxes) + { + FVector HalfSize = Box.Size * 0.5f; + FBox DBox(-HalfSize, HalfSize); + // Apply local rotation first then parent transform + FTransform CombinedXForm = FTransform(Box.Rotation, Box.Location) * XForm; + Ret->Boxes.Add(FStevesDebugRenderSceneProxy::FDebugBox( + DBox, Box.Colour, CombinedXForm)); + } + for (auto& Cylinder : Cylinders) + { + // Apply local rotation first then parent transform + const FTransform CombinedXForm = FTransform(Cylinder.Rotation, Cylinder.Location) * XForm; + const float HalfH = Cylinder.Height * 0.5f * CombinedXForm.GetScale3D().Z; + const float R = Cylinder.Radius * CombinedXForm.GetScale3D().Z; + const FVector Centre = CombinedXForm.TransformPosition(FVector::ZeroVector); + const FVector LocalX = CombinedXForm.TransformVectorNoScale(FVector::ForwardVector); + const FVector LocalY = CombinedXForm.TransformVectorNoScale(FVector::RightVector); + const FVector LocalZ = CombinedXForm.TransformVectorNoScale(FVector::UpVector); + Ret->CylindersImproved.Add(FStevesDebugRenderSceneProxy::FDebugCylinder( + Centre, LocalX, LocalY, LocalZ, R, HalfH, 16, Cylinder.Colour)); + } + for (auto& Capsule : Capsules) + { + // Apply local rotation first then parent transform + const FTransform CombinedXForm = FTransform(Capsule.Rotation, Capsule.Location) * XForm; + const float HalfH = Capsule.Height * 0.5f * CombinedXForm.GetScale3D().Z; + const float R = Capsule.Radius * CombinedXForm.GetScale3D().Z; + const FVector Position = CombinedXForm.TransformPosition(FVector::ZeroVector); + const FVector LocalX = CombinedXForm.TransformVectorNoScale(FVector::ForwardVector); + const FVector LocalY = CombinedXForm.TransformVectorNoScale(FVector::RightVector); + const FVector LocalZ = CombinedXForm.TransformVectorNoScale(FVector::UpVector); + Ret->CapsulesImproved.Add(FStevesDebugRenderSceneProxy::FCapsule( + Position, R, + LocalX, LocalY, LocalZ, + HalfH, Capsule.Colour)); + } + for (auto& Mesh : Meshes) + { + // Apply local rotation first then parent transform + if (IsValid(Mesh.Mesh)) + { + const FTransform CombinedXForm = FTransform(Mesh.Rotation, Mesh.Location, Mesh.Scale) * XForm; + const FStaticMeshLODResources& Lod = Mesh.Mesh->GetLODForExport(Mesh.bUseLowestLOD ? Mesh.Mesh->GetNumLODs() - 1 : 0); + TArray Vertices; + TArray Indices; + + Lod.IndexBuffer.GetCopy(Indices); + auto& PosBuffer = Lod.VertexBuffers.PositionVertexBuffer; + uint32 NumVerts = PosBuffer.GetNumVertices(); + Vertices.Reserve(NumVerts); + for (uint32 i = 0; i < NumVerts; ++i) + { + Vertices.Add(FDynamicMeshVertex(PosBuffer.VertexPosition(i))); + } + + Ret->MeshesImproved.Add(FStevesDebugRenderSceneProxy::FDebugMesh(CombinedXForm.ToMatrixWithScale(), Vertices, Indices, Mesh.Colour)); + + } + + + } + + return Ret; + +} + +FBoxSphereBounds UStevesEditorVisComponent::CalcBounds(const FTransform& LocalToWorld) const +{ + // Get superclass bounds in LOCAL space (don't pass LocalToWorld) + FBoxSphereBounds B = Super::CalcBounds(FTransform::Identity); + + // Now we need to merge in all components + for (auto& L : Lines) + { + // Re-centre the origin of the line to make box extents + FVector Extents = L.Start.GetAbs().ComponentMax(L.End.GetAbs()); + B = B + FBoxSphereBounds(FVector::ZeroVector, Extents, Extents.GetMax()); + } + for (auto& A : Arrows) + { + // Re-centre the origin of the line to make box extents + FVector Extents = A.Start.GetAbs().ComponentMax(A.End.GetAbs()); + B = B + FBoxSphereBounds(FVector::ZeroVector, Extents, Extents.GetMax()); + } + for (auto& C : Circles) + { + B = B + FBoxSphereBounds(C.Location, FVector(C.Radius), C.Radius); + } + for (auto& Arc : Arcs) + { + // Just use the entire circle for simplicity + B = B + FBoxSphereBounds(Arc.Location, FVector(Arc.Radius), Arc.Radius); + } + for (auto& S : Spheres) + { + B = B + FBoxSphereBounds(S.Location, FVector(S.Radius), S.Radius); + } + for (auto& Box : Boxes) + { + FVector HalfSize = Box.Size * 0.5f; + FBox DBox(-HalfSize, HalfSize); + // Apply local rotation only, world is done later + FTransform BoxXForm = FTransform(Box.Rotation, Box.Location); + DBox = DBox.TransformBy(BoxXForm); + B = B + FBoxSphereBounds(DBox); + } + for (auto& Cylinder : Cylinders) + { + FVector HalfSize = FVector(Cylinder.Radius, Cylinder.Radius, Cylinder.Height * 0.5f); + FBox DBox(-HalfSize, HalfSize); + // Apply local rotation only, world is done later + FTransform XForm = FTransform(Cylinder.Rotation, Cylinder.Location); + DBox = DBox.TransformBy(XForm); + B = B + FBoxSphereBounds(DBox); + } + for (auto& Capsule : Capsules) + { + FVector HalfSize = FVector(Capsule.Radius, Capsule.Radius, Capsule.Height * 0.5f + Capsule.Radius * 2.f); + FBox DBox(-HalfSize, HalfSize); + // Apply local rotation only, world is done later + FTransform XForm = FTransform(Capsule.Rotation, Capsule.Location); + DBox = DBox.TransformBy(XForm); + B = B + FBoxSphereBounds(DBox); + } + for (auto& Mesh : Meshes) + { + if (IsValid(Mesh.Mesh)) + { + const FTransform XForm = FTransform(Mesh.Rotation, Mesh.Location, Mesh.Scale); + B = B + Mesh.Mesh->GetBounds().TransformBy(XForm); + } + + } + return B.TransformBy(LocalToWorld); +} + diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesGameSubsystem.cpp b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesGameSubsystem.cpp new file mode 100644 index 00000000..e90a02c1 --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesGameSubsystem.cpp @@ -0,0 +1,716 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +#include "StevesGameSubsystem.h" + +#include "EngineUtils.h" +#include "EnhancedInputComponent.h" +#include "EnhancedInputSubsystems.h" +#include "StevesGameViewportClientBase.h" +#include "StevesPluginSettings.h" +#include "StevesUEHelpers.h" +#include "Engine/AssetManager.h" +#include "Engine/GameInstance.h" +#include "Framework/Application/SlateApplication.h" +#include "GameFramework/InputSettings.h" +#include "GameFramework/PlayerController.h" +#include "GameFramework/PlayerInput.h" +#include "StevesUI/KeySprite.h" +#include "StevesUI/StevesUI.h" +#include "TimerManager.h" +#include "AssetRegistry/AssetData.h" +#include "Engine/Engine.h" +#include "Engine/LocalPlayer.h" +#include "UnrealClient.h" +#include "Slate/SceneViewport.h" + +//PRAGMA_DISABLE_OPTIMIZATION + +void UStevesGameSubsystem::Initialize(FSubsystemCollectionBase& Collection) +{ + Super::Initialize(Collection); + +#if !UE_SERVER + CreateInputDetector(); + InitTheme(); + InitForegroundCheck(); + NotifyEnhancedInputMappingsChanged(); + InitViewport(); +#endif +} + +void UStevesGameSubsystem::Deinitialize() +{ + Super::Deinitialize(); +#if !UE_SERVER + DestroyInputDetector(); +#endif +} + +void UStevesGameSubsystem::InitViewport() +{ + FViewport::ViewportResizedEvent.AddUObject(this, &UStevesGameSubsystem::ViewportResized); + if (auto GI = GetGameInstance()) + { + if (auto VC = GI->GetGameViewportClient()) + { + VC->OnToggleFullscreen().AddUObject(this, &UStevesGameSubsystem::FullscreenToggled); + } + } +} + +void UStevesGameSubsystem::ViewportResized(FViewport* Viewport, unsigned Unused) +{ + FIntPoint Sz = Viewport->GetSizeXY(); + OnViewportResized.Broadcast(Sz.X, Sz.Y); +} + +void UStevesGameSubsystem::FullscreenToggled(bool bFullscreen) +{ + if (auto GI = GetGameInstance()) + { + if (auto VC = GI->GetGameViewportClient()) + { + FIntPoint Sz = VC->GetGameViewport()->GetSizeXY(); + OnViewportResized.Broadcast(Sz.X, Sz.Y); + } + } +} + +void UStevesGameSubsystem::CreateInputDetector() +{ +#if !UE_SERVER + if (!InputDetector.IsValid()) + { + InputDetector = MakeShareable(new FInputModeDetector()); + FSlateApplication::Get().RegisterInputPreProcessor(InputDetector); + + InputDetector->OnInputModeChanged.BindUObject(this, &UStevesGameSubsystem::OnInputDetectorModeChanged); + InputDetector->OnButtonInputModeChanged.BindUObject(this, &UStevesGameSubsystem::OnButtonInputDetectorModeChanged); + InputDetector->OnAxisInputModeChanged.BindUObject(this, &UStevesGameSubsystem::OnAxisInputDetectorModeChanged); + } +#endif +} + +void UStevesGameSubsystem::DestroyInputDetector() +{ +#if !UE_SERVER + if (InputDetector.IsValid()) + { + FSlateApplication::Get().UnregisterInputPreProcessor(InputDetector); + InputDetector.Reset(); + } +#endif +} + +void UStevesGameSubsystem::NotifyEnhancedInputMappingsChanged() +{ + // delay to ensure there's a tick in between which updates the mappings, it's not synchronous + auto DelayedFunc = [this]() + { + OnEnhancedInputMappingsChanged.Broadcast(); + }; + FTimerHandle TempHandle; + GetWorld()->GetTimerManager().SetTimer(TempHandle, FTimerDelegate::CreateLambda(DelayedFunc), 0.05f, false); +} + +TSoftObjectPtr UStevesGameSubsystem::FindEnhancedInputAction(const FString& Name) +{ + if (FAssetRegistryModule* AssetRegistryModule = FModuleManager::LoadModulePtr(TEXT("AssetRegistry"))) + { + IAssetRegistry& AssetRegistry = AssetRegistryModule->Get(); + if (auto Settings = GetDefault()) + { + for (const auto& Dir : Settings->EnhancedInputActionSearchDirectories) + { + if (!FPackageName::IsValidPath(Dir.Path)) + { + continue; + } + + TArray Assets; + FString Package = FPaths::Combine(Dir.Path, Name); + if (AssetRegistry.GetAssetsByPackageName(FName(*Package), Assets, true)) + { + for (const FAssetData& Asset : Assets) + { + if (Asset.GetClass() == UInputAction::StaticClass()) + { + return TSoftObjectPtr(Asset.GetSoftObjectPath()); + } + } + } + + } + } + } + return nullptr; +} + +void UStevesGameSubsystem::RegisterInterestInEnhancedInputAction(const UInputAction* Action, ETriggerEvent TriggerEvent) +{ + // Avoid registering duplicate interest + FEnhancedInputInterest Interest(Action, TriggerEvent); + if (!RegisteredEnhancedInputActionInterests.Contains(Interest)) + { + if (auto GI = GetGameInstance()) + { + if (auto PC = GI->GetFirstLocalPlayerController()) + { + if (auto EIC = Cast(PC->InputComponent)) + { + EIC->BindAction(Action, TriggerEvent, this, &ThisClass::EnhancedInputActionTriggered); + RegisteredEnhancedInputActionInterests.Add(Interest); + } + } + } + } + +} + +void UStevesGameSubsystem::UnregisterAllInterestInEnhancedInputActions() +{ + if (auto GI = GetGameInstance()) + { + if (auto PC = GI->GetFirstLocalPlayerController()) + { + if (auto EIC = Cast(PC->InputComponent)) + { + TArray HandlesToRemove; + for (auto& Binding : EIC->GetActionEventBindings()) + { + if (Binding->IsBoundToObject(this)) + { + HandlesToRemove.Add(Binding->GetHandle()); + } + } + + for (const uint32 Handle : HandlesToRemove) + { + EIC->RemoveBindingByHandle(Handle); + } + } + } + } + RegisteredEnhancedInputActionInterests.Empty(); +} + +void UStevesGameSubsystem::EnhancedInputActionTriggered(const FInputActionInstance& InputActionInstance) +{ + OnEnhancedInputActionTriggered.Broadcast(InputActionInstance.GetSourceAction(), InputActionInstance.GetTriggerEvent()); +} + + + +void UStevesGameSubsystem::InitTheme() +{ + DefaultUiTheme = LoadObject(nullptr, *DefaultUiThemePath, nullptr); +} + + +void UStevesGameSubsystem::InitForegroundCheck() +{ + // Check foreground status every 0.5 seconds + GetWorld()->GetTimerManager().SetTimer(ForegroundCheckHandle, this, &UStevesGameSubsystem::CheckForeground, 0.5, true); +} + +void UStevesGameSubsystem::CheckForeground() +{ + bool bNewForeground = bIsForeground; + + if (IsValid(GEngine) && IsValid(GEngine->GameViewport) && GEngine->GameViewport->Viewport) + bNewForeground = GEngine->GameViewport->Viewport->IsForegroundWindow(); + + if (bNewForeground != bIsForeground) + { + bIsForeground = bNewForeground; + InputDetector->bIgnoreEvents = !bIsForeground; + + OnWindowForegroundChanged.Broadcast(bIsForeground); + } + + +} +void UStevesGameSubsystem::OnInputDetectorModeChanged(int PlayerIndex, EInputMode NewMode) +{ + // We can't check this during Initialize because it's too early + if (!bCheckedViewportClient) + { + auto GI = GetGameInstance(); + auto VC = Cast(GI->GetGameViewportClient()); + if (!VC) + UE_LOG(LogStevesUEHelpers, Log, TEXT("Consider using UStevesGameViewportClientBase for your GameViewportClient")) + + bCheckedViewportClient = true; + + } + + auto Settings = GetDefault(); + if (Settings->bHideMouseWhenGamepadUsed) + { + auto GI = GetGameInstance(); + auto VC = GI->GetGameViewportClient(); + auto SVC = Cast(VC); + if (VC) + { + if (NewMode == EInputMode::Gamepad) + { + // First move mouse pointer out of the way because it still generates mouse hits (unless we make source changes to Slate, ugh) + MoveMouseOffScreen(true); + } + else if (NewMode == EInputMode::Mouse) + { + if (SVC) + SVC->SetSuppressMouseCursor(false); + } + } + } + OnInputModeChanged.Broadcast(PlayerIndex, NewMode); +} + +void UStevesGameSubsystem::MoveMouseOffScreen(bool bAlsoHide) const +{ + if (auto GI = GetGameInstance()) + { + auto VC = GI->GetGameViewportClient(); + auto SVC = Cast(VC); + auto PC = GI->GetFirstLocalPlayerController(); + + FVector2D Sz; + VC->GetViewportSize(Sz); + // -1 because if you move cursor outside window when captured, Slate blows up when you press Return, ughghh + // To make sure we don't generate a mouse move by doing this + // Bizarrely the ViewportSize is correct for moving the mouse pointer but the mouse events have different geometry + InputDetector->IgnoreNextMouseMove(); + PC->SetMouseLocation(Sz.X-1,Sz.Y-1); + + // Now hide it + // I've seen people use PC->bShowMouseCursor but this messes with capturing when you switch back & forth + // especially when pausing in the editor + // instead, I'm using a separate flag to suppress it, see UiFixGameViewportClient for usage + if (SVC && bAlsoHide) + SVC->SetSuppressMouseCursor(true); + } + +} + +void UStevesGameSubsystem::OnButtonInputDetectorModeChanged(int PlayerIndex, EInputMode NewMode) +{ + // This is specifically for button changes; if this is a different main input mode it will also be registered in OnInputDetectorModeChanged + // Just relay this one + OnButtonInputModeChanged.Broadcast(PlayerIndex, NewMode); +} + +void UStevesGameSubsystem::OnAxisInputDetectorModeChanged(int PlayerIndex, EInputMode NewMode) +{ + // This is specifically for button changes; if this is a different main input mode it will also be registered in OnInputDetectorModeChanged + // Just relay this one + OnAxisInputModeChanged.Broadcast(PlayerIndex, NewMode); +} + +FFocusSystem* UStevesGameSubsystem::GetFocusSystem() +{ + return &FocusSystem; +} + +UPaperSprite* UStevesGameSubsystem::GetInputImageSprite(EInputBindingType BindingType, + FName ActionOrAxis, + FKey Key, + EInputImageDevicePreference DevicePreference, + int PlayerIdx, + const UUiTheme* Theme) +{ + switch(BindingType) + { + case EInputBindingType::Action: + return GetInputImageSpriteFromAction(ActionOrAxis, DevicePreference, PlayerIdx, Theme); + case EInputBindingType::Axis: + return GetInputImageSpriteFromAxis(ActionOrAxis, DevicePreference, PlayerIdx, Theme); + case EInputBindingType::Key: + return GetInputImageSpriteFromKey(Key, PlayerIdx, Theme); + default: + return nullptr; + } +} + +// This is not threadsafe! But only used in UI thread in practice +TArray GS_TempActionMap; +TArray GS_TempAxisMap; + +UPaperSprite* UStevesGameSubsystem::GetInputImageSpriteFromAction(const FName& Name, + EInputImageDevicePreference DevicePreference, + int PlayerIdx, + const UUiTheme* Theme) +{ + UInputSettings* Settings = UInputSettings::GetInputSettings(); + GS_TempActionMap.Empty(); + Settings->GetActionMappingByName(Name, GS_TempActionMap); + + // For default, prefer latest press keyboard/mouse for buttons + if (DevicePreference == EInputImageDevicePreference::Auto) + DevicePreference = EInputImageDevicePreference::Gamepad_Keyboard_Mouse_Button; + + const EInputMode LastInput = GetLastInputModeUsed(PlayerIdx); + const EInputMode LastButtonInput = GetLastInputButtonPressed(PlayerIdx); + const EInputMode LastAxisInput = GetLastInputAxisMoved(PlayerIdx); + const auto Preferred = GetPreferedActionOrAxisMapping(GS_TempActionMap, Name, DevicePreference, LastInput, LastButtonInput, LastAxisInput); + if (Preferred) + { + return GetInputImageSpriteFromKey(Preferred->Key, PlayerIdx, Theme); + } + return nullptr; +} + +UPaperSprite* UStevesGameSubsystem::GetInputImageSpriteFromAxis(const FName& Name, + EInputImageDevicePreference DevicePreference, + int PlayerIdx, + const UUiTheme* Theme) +{ + // Look up the key for this axis + UInputSettings* Settings = UInputSettings::GetInputSettings(); + GS_TempAxisMap.Empty(); + Settings->GetAxisMappingByName(Name, GS_TempAxisMap); + + // For default, prefer mouse for axes + if (DevicePreference == EInputImageDevicePreference::Auto) + DevicePreference = EInputImageDevicePreference::Gamepad_Mouse_Keyboard; + + const EInputMode LastInput = GetLastInputModeUsed(PlayerIdx); + const EInputMode LastButtonInput = GetLastInputButtonPressed(PlayerIdx); + const EInputMode LastAxisInput = GetLastInputAxisMoved(PlayerIdx); + const auto Preferred = GetPreferedActionOrAxisMapping(GS_TempAxisMap, Name, DevicePreference, LastInput, LastButtonInput, LastAxisInput); + if (Preferred) + { + return GetInputImageSpriteFromKey(Preferred->Key, PlayerIdx, Theme); + } + return nullptr; +} + +UPaperSprite* UStevesGameSubsystem::GetInputImageSpriteFromEnhancedInputAction(UInputAction* Action, + EInputImageDevicePreference DevicePreference, + int PlayerIdx, + APlayerController* PC, + UUiTheme* Theme) +{ + + if (const UEnhancedInputLocalPlayerSubsystem* Subsystem = ULocalPlayer::GetSubsystem(PC->GetLocalPlayer())) + { + const TArray Keys = Subsystem->QueryKeysMappedToAction(Action); + + // For default, prefer mouse for axes + if (DevicePreference == EInputImageDevicePreference::Auto) + { + if (Action->ValueType == EInputActionValueType::Boolean) + { + DevicePreference = EInputImageDevicePreference::Gamepad_Keyboard_Mouse_Button; + } + else + { + DevicePreference = EInputImageDevicePreference::Gamepad_Mouse_Keyboard; + } + } + const EInputMode LastInput = GetLastInputModeUsed(PlayerIdx); + const EInputMode LastButtonInput = GetLastInputButtonPressed(PlayerIdx); + const EInputMode LastAxisInput = GetLastInputAxisMoved(PlayerIdx); + if (const FKey* PreferredKey = GetPreferedKeyMapping(Keys, DevicePreference, LastInput, LastButtonInput, LastAxisInput)) + { + return GetInputImageSpriteFromKey(*PreferredKey, PlayerIdx, Theme); + } + } + + return nullptr; +} + + +TSoftObjectPtr UStevesGameSubsystem::GetGamepadImages(int PlayerIndex, const UUiTheme* Theme) +{ + // TODO: determine type of controller + return Theme->XboxControllerImages; +} + +UPaperSprite* UStevesGameSubsystem::GetInputImageSpriteFromKey(const FKey& InKey, int PlayerIndex, const UUiTheme* Theme) +{ + if (!IsValid(Theme)) + Theme = GetDefaultUiTheme(); + + if (Theme) + { + if (InKey.IsGamepadKey()) + return GetImageSpriteFromTable(InKey, GetGamepadImages(PlayerIndex, Theme)); + else + return GetImageSpriteFromTable(InKey, Theme->KeyboardMouseImages); + } + + return nullptr; +} + + +UPaperSprite* UStevesGameSubsystem::GetImageSpriteFromTable(const FKey& InKey, + const TSoftObjectPtr& Asset) +{ + // Sync load for simplicity for now + const auto Table = Asset.LoadSynchronous(); + // Rows are named the same as the key name + const auto SpriteRow = Table->FindRow(InKey.GetFName(), "Find Key Image"); + if (SpriteRow) + { + return SpriteRow->Sprite; + } + return nullptr; +} + +void UStevesGameSubsystem::SetBrushFromAtlas(FSlateBrush* Brush, TScriptInterface AtlasRegion, bool bMatchSize) +{ + if(Brush->GetResourceObject() != AtlasRegion.GetObject()) + { + Brush->SetResourceObject(AtlasRegion.GetObject()); + + if (bMatchSize) + { + if (AtlasRegion) + { + const FSlateAtlasData AtlasData = AtlasRegion->GetSlateAtlasData(); + Brush->ImageSize = AtlasData.GetSourceDimensions(); + } + else + { + Brush->ImageSize = FVector2D(0, 0); + } + } + } +} + +FStevesTextureRenderTargetPoolPtr UStevesGameSubsystem::GetTextureRenderTargetPool(FName Name, bool bAutoCreate) +{ + // On the assumption there won't be *loads* of pools, not worth a map, just iterate + for (auto Tex : TextureRenderTargetPools) + { + if (Tex->GetName() == Name) + return Tex; + } + + if (bAutoCreate) + { + FStevesTextureRenderTargetPoolPtr Pool = MakeShared(Name, this); + TextureRenderTargetPools.Add(Pool); + return Pool; + } + + return nullptr; + +} + + +bool UStevesGameSubsystem::FInputModeDetector::ShouldProcessInputEvents() const +{ + return !bIgnoreEvents; +} + +UStevesGameSubsystem::FInputModeDetector::FInputModeDetector() +{ + // 4 local players should be plenty usually (will expand if necessary) + LastInputModeByPlayer.Init(DefaultInputMode, 4); + LastButtonPressByPlayer.Init(DefaultButtonInputMode, 4); + LastAxisMoveByPlayer.Init(DefaultAxisInputMode, 4); +} + +bool UStevesGameSubsystem::FInputModeDetector::HandleKeyDownEvent(FSlateApplication& SlateApp, const FKeyEvent& InKeyEvent) +{ + if (ShouldProcessInputEvents()) + { + // Key down also registers for gamepad buttons + ProcessKeyOrButton(InKeyEvent.GetUserIndex(), InKeyEvent.GetKey()); + } + + // Don't consume + return false; +} + +bool UStevesGameSubsystem::FInputModeDetector::HandleAnalogInputEvent(FSlateApplication& SlateApp, + const FAnalogInputEvent& InAnalogInputEvent) +{ + if (ShouldProcessInputEvents()) + { + if (InAnalogInputEvent.GetAnalogValue() > GamepadAxisThreshold) + SetMode(InAnalogInputEvent.GetUserIndex(), EInputMode::Gamepad, false); + } + + // Don't consume + return false; +} + +bool UStevesGameSubsystem::FInputModeDetector::HandleMouseMoveEvent(FSlateApplication& SlateApp, const FPointerEvent& MouseEvent) +{ + if (ShouldProcessInputEvents()) + { + if (bIgnoreNextMouseMove) + { + bIgnoreNextMouseMove = false; + } + else + { + FVector2D Dist = MouseEvent.GetScreenSpacePosition() - MouseEvent.GetLastScreenSpacePosition(); + if (FMath::Abs(Dist.X) > MouseMoveThreshold || FMath::Abs(Dist.Y) > MouseMoveThreshold) + { + SetMode(MouseEvent.GetUserIndex(), EInputMode::Mouse, false); + } + } + } + // Don't consume + return false; +} + +bool UStevesGameSubsystem::FInputModeDetector::HandleMouseButtonDownEvent(FSlateApplication& SlateApp, const FPointerEvent& MouseEvent) +{ + if (ShouldProcessInputEvents()) + { + // We don't care which button + SetMode(MouseEvent.GetUserIndex(), EInputMode::Mouse, true); + } + + // Don't consume + return false; +} + +bool UStevesGameSubsystem::FInputModeDetector::HandleMouseWheelOrGestureEvent(FSlateApplication& SlateApp, const FPointerEvent& InWheelEvent, + const FPointerEvent* InGestureEvent) +{ + if (ShouldProcessInputEvents()) + { + SetMode(InWheelEvent.GetUserIndex(), EInputMode::Mouse, false); + } + + // Don't consume + return false; +} + +EInputMode UStevesGameSubsystem::FInputModeDetector::GetLastInputMode(int PlayerIndex) +{ + if (PlayerIndex >= 0 && PlayerIndex < LastInputModeByPlayer.Num()) + return LastInputModeByPlayer[PlayerIndex]; + + // Assume default if never told + return DefaultInputMode; +} + +EInputMode UStevesGameSubsystem::FInputModeDetector::GetLastButtonInputMode(int PlayerIndex) +{ + if (PlayerIndex >= 0 && PlayerIndex < LastButtonPressByPlayer.Num()) + return LastButtonPressByPlayer[PlayerIndex]; + + // Assume default if never told + return DefaultButtonInputMode; +} + +EInputMode UStevesGameSubsystem::FInputModeDetector::GetLastAxisInputMode(int PlayerIndex) +{ + if (PlayerIndex >= 0 && PlayerIndex < LastAxisMoveByPlayer.Num()) + return LastAxisMoveByPlayer[PlayerIndex]; + + // Assume default if never told + return DefaultAxisInputMode; +} + +void UStevesGameSubsystem::FInputModeDetector::ProcessKeyOrButton(int PlayerIndex, FKey Key) +{ + if (Key.IsGamepadKey()) + { + SetMode(PlayerIndex, EInputMode::Gamepad, IsAGamepadButton(Key)); + } + else if (Key.IsMouseButton()) + { + // Assuming mice don't have analog buttons! + SetMode(PlayerIndex, EInputMode::Mouse, true); + } + else + { + // We assume anything that's not mouse and not gamepad is a keyboard + // Assuming keyboards don't have analog buttons! + SetMode(PlayerIndex, EInputMode::Keyboard, true); + } + +} + +bool UStevesGameSubsystem::FInputModeDetector::IsAGamepadButton(const FKey& Key) +{ + + // Key.IsButtonAxis() returns true for some thumbstick movement events, because the axis type is EInputAxisType::Button for + // some reason. That means you get button events for thumbstick movements, which is super dumb. + // See core engine InputCoreTypes.cpp for the stick axes which are defined FKeyDetails::GamepadKey | FKeyDetails::ButtonAxis + // This is for some kind of virtual input but it's a nasty hack, omit them + return Key.IsGamepadKey() && + Key != EKeys::Gamepad_LeftStick_Up && + Key != EKeys::Gamepad_LeftStick_Down && + Key != EKeys::Gamepad_LeftStick_Left && + Key != EKeys::Gamepad_LeftStick_Right && + Key != EKeys::Gamepad_RightStick_Up && + Key != EKeys::Gamepad_RightStick_Down && + Key != EKeys::Gamepad_RightStick_Left && + Key != EKeys::Gamepad_RightStick_Right; + +} + +void UStevesGameSubsystem::FInputModeDetector::SetMode(int PlayerIndex, EInputMode NewMode, bool bIsButton) +{ + bool bButtonChanged = false; + bool bAxisChanged = false; + bool bMainChanged = false; + + if (bIsButton) + { + if (NewMode != EInputMode::Unknown && NewMode != GetLastButtonInputMode(PlayerIndex)) + { + if (PlayerIndex >= LastButtonPressByPlayer.Num()) + LastButtonPressByPlayer.SetNum(PlayerIndex + 1); + + LastButtonPressByPlayer[PlayerIndex] = NewMode; + + bButtonChanged = true; + } + } + else + { + if (NewMode != EInputMode::Unknown && NewMode != GetLastAxisInputMode(PlayerIndex)) + { + if (PlayerIndex >= LastAxisMoveByPlayer.Num()) + LastAxisMoveByPlayer.SetNum(PlayerIndex + 1); + + LastAxisMoveByPlayer[PlayerIndex] = NewMode; + + bAxisChanged = true; + } + + } + // Whether it's a button or not it can affect the main input mode + if (NewMode != EInputMode::Unknown && NewMode != GetLastInputMode(PlayerIndex)) + { + if (PlayerIndex >= LastInputModeByPlayer.Num()) + LastInputModeByPlayer.SetNum(PlayerIndex + 1); + + LastInputModeByPlayer[PlayerIndex] = NewMode; + + bMainChanged = true; + } + + // Raise events at the end once all state has changed + if (bButtonChanged) + { + // ReSharper disable once CppExpressionWithoutSideEffects + OnButtonInputModeChanged.ExecuteIfBound(PlayerIndex, NewMode); + //UE_LOG(LogStevesUEHelpers, Display, TEXT("Button mode for player %d changed: %s"), PlayerIndex, *UEnum::GetValueAsString(NewMode)); + } + if (bAxisChanged) + { + // ReSharper disable once CppExpressionWithoutSideEffects + OnAxisInputModeChanged.ExecuteIfBound(PlayerIndex, NewMode); + //UE_LOG(LogStevesUEHelpers, Display, TEXT("Axis mode for player %d changed: %s"), PlayerIndex, *UEnum::GetValueAsString(NewMode)); + } + if (bMainChanged) + { + // ReSharper disable once CppExpressionWithoutSideEffects + OnInputModeChanged.ExecuteIfBound(PlayerIndex, NewMode); + //UE_LOG(LogStevesUEHelpers, Display, TEXT("Input mode for player %d changed: %s"), PlayerIndex, *UEnum::GetValueAsString(NewMode)); + } + +} + +//PRAGMA_ENABLE_OPTIMIZATION \ No newline at end of file diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesGameViewportClientBase.cpp b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesGameViewportClientBase.cpp new file mode 100644 index 00000000..aea4acb8 --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesGameViewportClientBase.cpp @@ -0,0 +1,31 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +#include "StevesGameViewportClientBase.h" + + +#include "Engine/Console.h" +#include "Engine/GameInstance.h" +#include "Framework/Application/SlateApplication.h" + +void UStevesGameViewportClientBase::Init(FWorldContext& WorldContext, UGameInstance* OwningGameInstance, + bool bCreateNewAudioDevice) +{ + Super::Init(WorldContext, OwningGameInstance, bCreateNewAudioDevice); + + bSuppressMouseCursor = false; +} + +EMouseCursor::Type UStevesGameViewportClientBase::GetCursor(FViewport* InViewport, int32 X, int32 Y) +{ + if (FSlateApplication::Get().IsActive() && bSuppressMouseCursor) + return EMouseCursor::None; + + return Super::GetCursor(InViewport, X, Y); +} + +void UStevesGameViewportClientBase::SetSuppressMouseCursor(bool bSuppress) +{ + bSuppressMouseCursor = bSuppress; + FSlateApplication::Get().OnCursorSet(); // necessary to make slate wake up + +} diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesLightFlicker.cpp b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesLightFlicker.cpp new file mode 100644 index 00000000..95593eea --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesLightFlicker.cpp @@ -0,0 +1,202 @@ +// Copyright Steve Streeting +// Licensed under the MIT License (see License.txt) +#include "StevesLightFlicker.h" + +#include "Net/UnrealNetwork.h" + +TMap UStevesLightFlickerHelper::Curves; +TMap UStevesLightFlickerHelper::CustomCurves; +FCriticalSection UStevesLightFlickerHelper::CriticalSection; + +const TMap UStevesLightFlickerHelper::StandardPatterns { + // Quake lighting flicker functions + // https://github.com/id-Software/Quake/blob/bf4ac424ce754894ac8f1dae6a3981954bc9852d/qw-qc/world.qc#L328-L372 + { EStevesLightFlickerPattern::Flicker1, TEXT("mmnmmommommnonmmonqnmmo") }, + { EStevesLightFlickerPattern::SlowStrongPulse, TEXT("abcdefghijklmnopqrstuvwxyzyxwvutsrqponmlkjihgfedcba") }, + { EStevesLightFlickerPattern::Candle1, TEXT("mmmmmaaaaammmmmaaaaaabcdefgabcdefg") }, + { EStevesLightFlickerPattern::FastStrobe, TEXT("mamamamamama") }, + { EStevesLightFlickerPattern::GentlePulse1, TEXT("jklmnopqrstuvwxyzyxwvutsrqponmlkj") }, + { EStevesLightFlickerPattern::Flicker2, TEXT("nmonqnmomnmomomno") }, + { EStevesLightFlickerPattern::Candle2, TEXT("mmmaaaabcdefgmmmmaaaammmaamm") }, + { EStevesLightFlickerPattern::Candle3, TEXT("mmmaaammmaaammmabcdefaaaammmmabcdefmmmaaaa") }, + { EStevesLightFlickerPattern::SlowStrobe, TEXT("aaaaaaaazzzzzzzz") }, + { EStevesLightFlickerPattern::FlourescentFlicker, TEXT("mmamammmmammamamaaamammma") }, + { EStevesLightFlickerPattern::SlowPulseNoBlack, TEXT("abcdefghijklmnopqrrqponmlkjihgfedcba") }, + + /// Eniko's torch pattern from Kitsune Tails + { EStevesLightFlickerPattern::Torch1, TEXT("mnkjcfcdafdehifkjlm") }, + /// No black version of Torch1 + { EStevesLightFlickerPattern::Torch2, TEXT("mnkjcfcecfdehifkjlm") } +}; + +float UStevesLightFlickerHelper::EvaluateLightCurve(EStevesLightFlickerPattern CurveType, float Time) +{ + return GetLightCurve(CurveType).Eval(Time); +} + +const FRichCurve& UStevesLightFlickerHelper::GetLightCurve(EStevesLightFlickerPattern CurveType) +{ + FScopeLock ScopeLock(&CriticalSection); + + if (auto pCurve = Curves.Find(CurveType)) + { + return *pCurve; + } + + auto& Curve = Curves.Emplace(CurveType); + BuildCurve(CurveType, Curve); + return Curve; +} + +const FRichCurve& UStevesLightFlickerHelper::GetLightCurve(const FString& CurveStr) +{ + FScopeLock ScopeLock(&CriticalSection); + + if (auto pCurve = CustomCurves.Find(CurveStr)) + { + return *pCurve; + } + + auto& Curve = CustomCurves.Emplace(CurveStr); + BuildCurve(CurveStr, Curve); + return Curve; +} + +void UStevesLightFlickerHelper::BuildCurve(EStevesLightFlickerPattern CurveType, FRichCurve& OutCurve) +{ + if (auto pTxt = StandardPatterns.Find(CurveType)) + { + BuildCurve(*pTxt, OutCurve); + } + +} + +void UStevesLightFlickerHelper::BuildCurve(const FString& QuakeCurveChars, FRichCurve& OutCurve) +{ + OutCurve.Reset(); + + for (int i = 0; i < QuakeCurveChars.Len(); ++i) + { + // We actually build the curve a..z = 0..1, and then use a default max value of 2 to restore the original behaviour. + // Actually the curve is 0..1.04 due to original behaviour that z is 2.08 not 2 + const int CharIndex = QuakeCurveChars[i] - 'a'; + const float Val = (float)CharIndex / 24.f; // to ensure m==1, z==2.08 (rescaled to half that so 0..1.04) + // Quake default was each character was 0.1s + OutCurve.AddKey(i * 0.1f, Val); + } + + // To catch empty + if (QuakeCurveChars.IsEmpty()) + { + OutCurve.AddKey(0, 1); + } +} + +UStevesLightFlickerComponent::UStevesLightFlickerComponent(const FObjectInitializer& Initializer): + Super(Initializer), + TimePos(0), + CurrentValue(0), + Curve(nullptr) +{ + PrimaryComponentTick.bCanEverTick = true; + PrimaryComponentTick.bTickEvenWhenPaused = false; + PrimaryComponentTick.bStartWithTickEnabled = false; +} + +void UStevesLightFlickerComponent::BeginPlay() +{ + Super::BeginPlay(); + + GenerateCurveAndPlay(); +} + +void UStevesLightFlickerComponent::GenerateCurveAndPlay() +{ + if (FlickerPattern == EStevesLightFlickerPattern::Custom) + { + Curve = &UStevesLightFlickerHelper::GetLightCurve(CustomFlickerPattern); + } + else + { + Curve = &UStevesLightFlickerHelper::GetLightCurve(FlickerPattern); + } + TimePos = 0; + if (bAutoPlay) + { + Play(); + } +} + +void UStevesLightFlickerComponent::ValueUpdate() +{ + CurrentValue = FMath::Lerp(MinValue, MaxValue, Curve->Eval(TimePos)); + OnLightFlickerUpdate.Broadcast(CurrentValue); +} + +void UStevesLightFlickerComponent::Play(bool bResetTime) +{ + if (GetOwnerRole() == ROLE_Authority || !GetIsReplicated()) + { + if (bResetTime) + { + TimePos = 0; + } + ValueUpdate(); + + PrimaryComponentTick.SetTickFunctionEnable(true); + } +} + +void UStevesLightFlickerComponent::Pause() +{ + if (GetOwnerRole() == ROLE_Authority || !GetIsReplicated()) + { + PrimaryComponentTick.SetTickFunctionEnable(false); + } +} + +float UStevesLightFlickerComponent::GetCurrentValue() const +{ + return CurrentValue; +} + +void UStevesLightFlickerComponent::OnRep_TimePos() +{ + ValueUpdate(); +} + +void UStevesLightFlickerComponent::TickComponent(float DeltaTime, + ELevelTick TickType, + FActorComponentTickFunction* ThisTickFunction) +{ + Super::TickComponent(DeltaTime, TickType, ThisTickFunction); + + TimePos += DeltaTime * Speed; + const float MaxTime = Curve->GetLastKey().Time; + while (TimePos > MaxTime) + { + TimePos -= MaxTime; + } + ValueUpdate(); +} + +void UStevesLightFlickerComponent::SetFlickerPattern(EStevesLightFlickerPattern Pattern, + const FString& CustomPatternString) +{ + FlickerPattern = Pattern; + CustomFlickerPattern = CustomPatternString; + GenerateCurveAndPlay(); +} + +EStevesLightFlickerPattern UStevesLightFlickerComponent::GetFlickerPattern(FString& CustomString) +{ + CustomString = CustomFlickerPattern; + return FlickerPattern; +} + +void UStevesLightFlickerComponent::GetLifetimeReplicatedProps(TArray< FLifetimeProperty > & OutLifetimeProps) const +{ + Super::GetLifetimeReplicatedProps(OutLifetimeProps); + + DOREPLIFETIME(UStevesLightFlickerComponent, TimePos); +} diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesMathHelpers.cpp b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesMathHelpers.cpp new file mode 100644 index 00000000..93f073d6 --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesMathHelpers.cpp @@ -0,0 +1,252 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +#include "StevesMathHelpers.h" + +#include "Chaos/CastingUtilities.h" +#include "Chaos/GeometryQueries.h" +#include "PhysicsEngine/ConvexElem.h" +#include "Runtime/Launch/Resources/Version.h" +#include "Chaos/ChaosEngineInterface.h" + + +bool StevesMathHelpers::OverlapConvex(const FKConvexElem& Convex, + const FTransform& ConvexTransform, + const FCollisionShape& Shape, + const FVector& ShapePos, + const FQuat& ShapeRot, + FMTDResult& OutResult) +{ + const FPhysicsShapeAdapter ShapeAdapter(ShapeRot, Shape); +#if ENGINE_MAJOR_VERSION == 5 && ENGINE_MINOR_VERSION >= 4 + const Chaos::FConvexPtr& ChaosConvex = Convex.GetChaosConvexMesh(); +#else + const TSharedPtr& ChaosConvex = Convex.GetChaosConvexMesh(); +#endif + if (!ChaosConvex.IsValid()) + return false; + +#if ENGINE_MAJOR_VERSION == 5 && ENGINE_MINOR_VERSION >= 4 + const auto& ChaosConvexObj = *ChaosConvex.GetReference(); +#else + const auto& ChaosConvexObj = *ChaosConvex.Get(); +#endif + + const Chaos::FImplicitObject& ShapeGeom = ShapeAdapter.GetGeometry(); + const FTransform& ShapeGeomTransform = ShapeAdapter.GetGeomPose(ShapePos); + + OutResult.Distance = 0; + + bool bHasOverlap = false; + + Chaos::FMTDInfo MTDInfo; + if (Chaos::Utilities::CastHelper(ShapeGeom, + ShapeGeomTransform, + [&](const auto& Downcast, const auto& FullGeomTransform) + { + return Chaos::OverlapQuery(ChaosConvexObj, + ConvexTransform, + Downcast, + FullGeomTransform, + /*Thickness=*/ + 0, + &MTDInfo); + })) + { + bHasOverlap = true; + OutResult.Distance = MTDInfo.Penetration; + OutResult.Direction = MTDInfo.Normal; + } + + return bHasOverlap; +} + +float StevesMathHelpers::GetDistanceToConvex2D(const TArray& ConvexPoints, const FVector& LocalPoint) +{ + return GetDistanceToConvex2D(ConvexPoints, FVector2f(LocalPoint.X, LocalPoint.Y)); +} + +float StevesMathHelpers::GetDistanceToConvex2D(const TArray& ConvexPoints, const FVector2f& LocalPoint) +{ + // Assume inside until 1 or more tests show it's outside + bool bInside = true; + float ClosestOutside = 1e30f; + float ClosestInside = 1e30f; + const int N = ConvexPoints.Num(); + for (int i = 0; i < N; ++i) + { + const int OtherIdx = (i + 1) % N; + const FVector2f& Start = ConvexPoints[i]; + const FVector2f& End = ConvexPoints[OtherIdx]; + const FVector2f Line = End - Start; + // Determine inside / outside first + // This may be the distance, but might not be if perpendicular projection is outside line segment + // So save the normalise for later + // Simple cross product to get the (non unit length) normal + const FVector2f Normal = FVector2f(-Line.Y, Line.X); + const FVector2f ToPoint = LocalPoint - ConvexPoints[i]; + const float DotNormal = Normal.Dot(ToPoint); + + if (DotNormal > 0) + { + // If >0 result is outside, point must be outside + bInside = false; + } + + // Do a perpendicular projection onto the line segment to see if we're within the limits of it + const float DotLine = Line.Dot(ToPoint); + const float T = DotLine / Line.SquaredLength(); + + float Dist; + if (T < 0) + { + // Outside line segment, closest point is start + Dist = (LocalPoint - Start).Length(); + } + else if (T > 1) + { + // Outside line segment, closest point is end + Dist = (LocalPoint - End).Length(); + } + else + { + // Within line segment + Dist = FMath::Sqrt(ToPoint.SquaredLength() - FMath::Square(T * Line.Length())); + } + + if (DotNormal > 0) + { + ClosestOutside = FMath::Min(ClosestOutside, Dist); + } + else + { + ClosestInside = FMath::Min(ClosestInside, Dist); + } + } + + return bInside ? -ClosestInside : ClosestOutside; +} + +int StevesMathHelpers::Fill2DRegionWithRectangles(int StartX, + int StartY, + int Width, + int Height, + std::function CellIncludeFunc, + TArray& OutRects) +{ + int RectCount = 0; + + const int Len = Width*Height; + TArray bDoneMarkers; + bDoneMarkers.SetNumUninitialized(Len); + int CellsTodo = 0; + + int StartN = 0; + const int EndX = StartX + Width - 1; + const int EndY = StartY + Height - 1; + // Initialise done markers based on func + for (int y = 0; y < Height; ++y) + { + for (int x = 0; x < Width; ++x) + { + const bool Include = CellIncludeFunc(x+StartX, y+StartY); + bDoneMarkers[y*Width + x] = !Include; + if (Include) + { + if (++CellsTodo == 1) + { + // This is the one we'll start with, might as well calculate it while we're here + StartN = y*Width + x; + } + } + } + } + + while (CellsTodo > 0) + { + // Find next starting point, from last one, until not done + for (; StartN < Len && bDoneMarkers[StartN]; ++StartN) {} + + // Shouldn't happen, but just in case + if (StartN >= Len) + break; + + // NOTE: this X/Y is local (based at 0,0 not StartX/StartY, for use with DoneMarkers) + const int LocalStartX = StartN % Width; + const int LocalStartY = StartN / Width; + + // We try not to create long & thin rects if we can help it, unlike greedy meshing + // We're greedy in alternate dims to try to make fatter quads + bool bCanExtendX = true, bCanExtendY = true; + bool bExtendingX = true; + int W = 1; + int H = 1; + + while (bCanExtendX || bCanExtendY) + { + // Try extending right + if (bExtendingX) + { + bool ExtendOK = LocalStartX+W < Width; + for (int TestY = LocalStartY; ExtendOK && TestY < LocalStartY+H; ++TestY) + { + if (bDoneMarkers[TestY*Width + LocalStartX+W]) + { + // No good + ExtendOK = false; + } + } + if (ExtendOK) + { + ++W; + } + else + { + bCanExtendX = false; + } + // Flip extending axis if possible + if (bCanExtendY) + bExtendingX = false; + } + else + { + // Try extending down + bool ExtendOK = LocalStartY+H < Height; + for (int TestX = LocalStartX; ExtendOK && TestX < LocalStartX+W; ++TestX) + { + if (bDoneMarkers[(LocalStartY+H)*Width + TestX]) + { + // No good + ExtendOK = false; + } + } + if (ExtendOK) + { + ++H; + } + else + { + bCanExtendY = false; + } + // Flip extending axis if possible + if (bCanExtendX) + bExtendingX = true; + } + } + + // We've calculated the max extension + for (int y = LocalStartY; y < LocalStartY+H; ++y) + { + for (int x = LocalStartX; x < LocalStartX+W; ++x) + { + bDoneMarkers[y*Width+x] = true; + --CellsTodo; + } + } + OutRects.Add(FIntRect(StartX+LocalStartX, StartY+LocalStartY, StartX+LocalStartX+W-1, StartY+LocalStartY+H-1)); + ++RectCount; + + } + + return RectCount; + +} diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesPluginSettings.cpp b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesPluginSettings.cpp new file mode 100644 index 00000000..0c851c93 --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesPluginSettings.cpp @@ -0,0 +1,4 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license + +#include "StevesPluginSettings.h" diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesPooledActorSystem.cpp b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesPooledActorSystem.cpp new file mode 100644 index 00000000..71f1763b --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesPooledActorSystem.cpp @@ -0,0 +1,190 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license + +#include "StevesPooledActorSystem.h" +#include "Components/PrimitiveComponent.h" +#include "Runtime/Launch/Resources/Version.h" +#include "StevesPooledActor.h" + +UStevesPooledActorSystem* UStevesPooledActorSystem::Get(const UObject* WorldContext) +{ + if (IsValid(WorldContext)) + { + if (auto World = WorldContext->GetWorld()) + { + return World->GetSubsystem(); + } + } + + return nullptr; +} + +TDeque>* UStevesPooledActorSystem::GetPool(UClass* Class, bool bCreate) +{ + auto pPool = Pools.Find(Class); + if (!pPool && bCreate) + { + // Create new pool + pPool = &Pools.Emplace(Class); + } + + return pPool; +} + +void UStevesPooledActorSystem::AddActorToPool(AActor* Actor) +{ + if (!IsValid(Actor)) + { + return; + } + + UClass* Class = Actor->GetClass()->GetAuthoritativeClass(); + + if (auto pPool = GetPool(Class, true)) + { + pPool->PushLast(Actor); + DisableActor(Actor); + } +} + +void UStevesPooledActorSystem::ReleasePooledActor(AActor* Actor) +{ + // This is really just a synonym so that Get/Release pattern is more pleasingly symmetrical + // We have AddActorToPool for externally created actors + AddActorToPool(Actor); +} + +AActor* UStevesPooledActorSystem::GetPooledActor(TSubclassOf ActorClass, + FVector const& Location, + FRotator const& Rotation, + bool& bWasReused) +{ + UClass* Class = ActorClass->GetAuthoritativeClass(); + + if (auto pPool = GetPool(Class, false)) + { + // We use the end of the deque meaning we get most recently used actor for better potential cache use + TObjectPtr Ret = nullptr; + // In case someone has destroyed the actors we've got in the pool, keep trying if not valid until empty + while (pPool->TryPopLast(Ret)) + { + if (IsValid(Ret.Get())) + { + ReviveActor(Ret.Get(), Location, Rotation); + bWasReused = true; + return Ret.Get(); + } + } + } + + // We need to spawn a new one + // We don't add this to the pool of course, because the caller is getting it + bWasReused = false; + return SpawnNewActor(Class, Location, Rotation); +} + +void UStevesPooledActorSystem::PreWarmActorPool(TSubclassOf ActorClass, + int Count) +{ + UClass* Class = ActorClass->GetAuthoritativeClass(); + if (auto pPool = GetPool(Class, true)) + { + for (int i = pPool->Num(); i < Count; ++i) + { + auto Actor = SpawnNewActor(Class, StorageLocation, FRotator::ZeroRotator); + DisableActor(Actor); + pPool->PushLast(TObjectPtr(Actor)); + } + } +} + +AActor* UStevesPooledActorSystem::SpawnNewActor(UClass* Class, const FVector& Location, const FRotator& Rotation) +{ + FActorSpawnParameters Params; + Params.Name = FName(FString::Printf(TEXT("Pooled_%s"), *Class->GetName())); + Params.NameMode = FActorSpawnParameters::ESpawnActorNameMode::Requested; + Params.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AdjustIfPossibleButAlwaysSpawn; + AActor* Ret = GetWorld()->SpawnActor(Class, &Location, &Rotation, Params); +#if WITH_EDITOR + Ret->SetActorLabel(Ret->GetName()); +#endif + return Ret; +} + +void UStevesPooledActorSystem::DisableActor(AActor* Actor) +{ + if (Actor) + { + Actor->SetActorHiddenInGame(true); + if (auto Prim = Cast(Actor->GetRootComponent())) + { + Prim->SetSimulatePhysics(false); +#if ENGINE_MAJOR_VERSION >= 5 && ENGINE_MINOR_VERSION >= 3 + Prim->ResetSceneVelocity(); +#endif + } + Actor->SetActorLocation(StorageLocation, false, nullptr, ETeleportType::ResetPhysics); + + if (Actor->Implements()) + { + IStevesPooledActor::Execute_DeactivateOnAddedToPool(Actor); + } + } +} + +void UStevesPooledActorSystem::ReviveActor(AActor* Actor, const FVector& Location, const FRotator& Rotator) +{ + if (Actor) + { + Actor->SetActorHiddenInGame(false); + // We don't enable physics, because caller may not want that. + Actor->SetActorLocation(Location, false, nullptr, ETeleportType::ResetPhysics); + Actor->SetActorRotation(Rotator, ETeleportType::ResetPhysics); + + if (Actor->Implements()) + { + IStevesPooledActor::Execute_ReactivateOnRemovedFromPool(Actor); + } + } +} + +void UStevesPooledActorSystem::DrainActorPool(TSubclassOf ActorClass, int NumberToKeep) +{ + UClass* Class = ActorClass->GetAuthoritativeClass(); + if (auto pPool = GetPool(Class, false)) + { + DrainPool(pPool, NumberToKeep); + } +} + +void UStevesPooledActorSystem::DrainPool(TDeque>* pPool, int NumberToKeep) +{ + while (pPool->Num() > NumberToKeep) + { + TObjectPtr Actor = nullptr; + if(pPool->TryPopLast(Actor)) + { + Actor->Destroy(); + } + else + { + // Should never happen but just in case, prevent infinite loop + break; + } + } +} + +void UStevesPooledActorSystem::DrainAllActorPools() +{ + for (auto& Pair : Pools) + { + DrainPool(&Pair.Value, 0); + } +} + +void UStevesPooledActorSystem::Deinitialize() +{ + Super::Deinitialize(); + + DrainAllActorPools(); +} diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesReplicatedPhysicsActor.cpp b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesReplicatedPhysicsActor.cpp new file mode 100644 index 00000000..e3d9d77d --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesReplicatedPhysicsActor.cpp @@ -0,0 +1,74 @@ +// Copyright Steve Streeting +// Licensed under the MIT License (see License.txt) +#include "StevesReplicatedPhysicsActor.h" +#include "Runtime/Launch/Resources/Version.h" +#include "Components/StaticMeshComponent.h" +#include "Engine/World.h" + + +AStevesReplicatedPhysicsActor::AStevesReplicatedPhysicsActor(const FObjectInitializer& ObjInit) +{ + PrimaryActorTick.bCanEverTick = false; + + bReplicates = true; + bStaticMeshReplicateMovement = true; + + const auto MeshComp = GetStaticMeshComponent(); + MeshComp->SetMobility(EComponentMobility::Movable); + MeshComp->SetCollisionObjectType(ECC_WorldDynamic); + // I think this is actually not needed, since on clients the role is ROLE_SimulatedProxy not ROLE_AutonomousProxy + // When using ROLE_SimulatedProxy physics is replicated all the time + MeshComp->bReplicatePhysicsToAutonomousProxy = true; + + // The default MinNetUpdateFrequency of 2 is too slow at responding to woken physics objects +#if ENGINE_MINOR_VERSION >= 5 + SetMinNetUpdateFrequency(10); +#else + MinNetUpdateFrequency = 10; +#endif + + // We do NOT replicate MeshComp itself! That's expensive and unnecessary + +} + +void AStevesReplicatedPhysicsActor::BeginPlay() +{ + SetReplicateMovement(true); + + const auto MeshComp = GetStaticMeshComponent(); + + // Server test, includes dedicated and listen servers + if (IsServer()) + { + // Server collision. Block all but allow pawns through + // Subclasses can change this if they like + MeshComp->SetCollisionResponseToAllChannels(ECR_Block); + MeshComp->SetCollisionResponseToChannel(ECC_Pawn, ECR_Overlap); + MeshComp->SetSimulatePhysics(true); + } + else + { + // We're on a client so will be receiving replicated physics + // Ignore all collisions; if we don't do this, things jank because of conflicting collisions + MeshComp->SetCollisionResponseToAllChannels(ECR_Ignore); + + // We DON'T disable physics. Physics still needs to be enabled for replicated physics to be respected. + // Otherwise objects will not move on the client. + + // There's one case where this is problematic: replicated physics actors that are already asleep + // once a new client joins a game in-progress will locally simulate themselves through the floor, because no further + // updates will be received about their physics from the server. + // My solution is to start all objects asleep on the client; if they're not, the server will send updates and + // the objects will wake up. If they're asleep on the server, we're good. + MeshComp->PutAllRigidBodiesToSleep(); + } + + Super::BeginPlay(); +} + +bool AStevesReplicatedPhysicsActor::IsServer() const +{ + // Server test, includes dedicated and listen servers + // I prefer this to Authority test because you can't replicate objects client->server or client->client anyway + return GetWorld()->GetNetMode() < NM_Client; +} diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesSpringArmComponent.cpp b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesSpringArmComponent.cpp new file mode 100644 index 00000000..fbd4fb6f --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesSpringArmComponent.cpp @@ -0,0 +1,116 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license + +#include "StevesSpringArmComponent.h" +#include "VisualLogger/VisualLogger.h" +#include "StevesUEHelpers.h" + + + +UStevesSpringArmComponent::UStevesSpringArmComponent(): bEnableSmoothCollisionAvoidance(1) +{ +} + +void UStevesSpringArmComponent::UpdateDesiredArmLocation(bool bDoTrace, + bool bDoLocationLag, + bool bDoRotationLag, + float DeltaTime) +{ + // Smooth target & socket offsets + if (SmoothTargetOffsetTarget.IsSet()) + { + TargetOffset = FMath::VInterpTo(TargetOffset, SmoothTargetOffsetTarget.GetValue(), DeltaTime, SmoothTargetOffsetSpeed); + if (TargetOffset.Equals(SmoothTargetOffsetTarget.GetValue())) + { + CancelTargetOffsetSmooth(); + } + } + if (SmoothSocketOffsetTarget.IsSet()) + { + SocketOffset = FMath::VInterpTo(SocketOffset, SmoothSocketOffsetTarget.GetValue(), DeltaTime, SmoothSocketOffsetSpeed); + if (SocketOffset.Equals(SmoothSocketOffsetTarget.GetValue())) + { + CancelSocketOffsetSmooth(); + } + } + Super::UpdateDesiredArmLocation(bDoTrace, bDoLocationLag, bDoRotationLag, DeltaTime); +} + +FVector UStevesSpringArmComponent::BlendLocations(const FVector& DesiredArmLocation, + const FVector& TraceHitLocation, + bool bHitSomething, + float DeltaTime) +{ + +#if WITH_EDITORONLY_DATA && ENABLE_VISUAL_LOG + if (bHitSomething && bVisualLogCameraCollision) + { + FVector NewDesiredLoc = PreviousDesiredLoc - PreviousDesiredRot.Vector() * TargetArmLength; + // Add socket offset in local space + NewDesiredLoc += FRotationMatrix(PreviousDesiredRot).TransformVector(SocketOffset); + FCollisionQueryParams QueryParams(SCENE_QUERY_STAT(SpringArm), false, GetOwner()); + FHitResult Result; + if (GetWorld()->SweepSingleByChannel(Result, PreviousArmOrigin, NewDesiredLoc, FQuat::Identity, ProbeChannel, FCollisionShape::MakeSphere(ProbeSize), QueryParams)) + { + UE_VLOG_ARROW(this, LogStevesUEHelpers, Log, PreviousArmOrigin, Result.Location, FColor::Cyan, TEXT("")); + UE_VLOG_LOCATION(this, LogStevesUEHelpers, Log, Result.Location, ProbeSize, FColor::Cyan, TEXT("%s"), *Result.GetActor()->GetActorNameOrLabel()); + } + } +#endif + + // These locations are in world space, we only want to blend the arm length, not the rest + const FVector Base = Super::BlendLocations(DesiredArmLocation, TraceHitLocation, bHitSomething, DeltaTime); + + if (bEnableSmoothCollisionAvoidance) + { + // Convert these back to arm lengths + // PreviousArmOrigin has been already set and is the world space origin + const float TargetArmLen = (Base - PreviousArmOrigin).Length(); + + const float NewArmLen = FMath::FInterpTo(PrevArmLength.Get(TargetArmLen), TargetArmLen, DeltaTime, SmoothCollisionAvoidanceSpeed); + + // Perform the same transformation again using the new arm length + // Now offset camera position back along our rotation + FVector Ret = PreviousDesiredLoc - PreviousDesiredRot.Vector() * NewArmLen; + // Add socket offset in local space + Ret += FRotationMatrix(PreviousDesiredRot).TransformVector(SocketOffset); + + PrevArmLength = NewArmLen; + + return Ret; + } + else + { + return Base; + } + +} + +void UStevesSpringArmComponent::SetTargetOffsetSmooth(const FVector& NewTargetOffset, float Speed) +{ + SmoothTargetOffsetTarget = NewTargetOffset; + SmoothTargetOffsetSpeed = Speed; +} + +void UStevesSpringArmComponent::CancelTargetOffsetSmooth() +{ + SmoothTargetOffsetTarget.Reset(); +} + +void UStevesSpringArmComponent::SetSocketOffsetSmooth(const FVector& NewSocketOffset, float Speed) +{ + SmoothSocketOffsetTarget = NewSocketOffset; + SmoothSocketOffsetSpeed = Speed; +} + +void UStevesSpringArmComponent::CancelSocketOffsetSmooth() +{ + SmoothSocketOffsetTarget.Reset(); +} + +void UStevesSpringArmComponent::JumpToDesiredLocation() +{ + CancelTargetOffsetSmooth(); + CancelSocketOffsetSmooth(); + UpdateDesiredArmLocation(false, false, false, 0); +} diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesTextureRenderTargetPool.cpp b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesTextureRenderTargetPool.cpp new file mode 100644 index 00000000..077aaf26 --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesTextureRenderTargetPool.cpp @@ -0,0 +1,132 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +#include "StevesTextureRenderTargetPool.h" + +#include "StevesUEHelpers.h" +#include "Kismet/KismetRenderingLibrary.h" +#include "UObject/UObjectGlobals.h" +#include "UObject/Package.h" + +FStevesTextureRenderTargetReservation::~FStevesTextureRenderTargetReservation() +{ + //UE_LOG(LogStevesUEHelpers, Log, TEXT("FStevesTextureRenderTargetReservation: destruction")); + if (ParentPool.IsValid() && Texture.IsValid()) + { + ParentPool.Pin()->ReleaseReservation(Texture.Get()); + Texture = nullptr; + } +} + +void FStevesTextureRenderTargetPool::ReleaseReservation(UTextureRenderTarget2D* Tex) +{ + if (!Tex) + { + UE_LOG(LogStevesUEHelpers, Warning, TEXT("FStevesTextureRenderTargetPool: Attempted to release a null texture")); + return; + } + + for (int i = 0; i < Reservations.Num(); ++i) + { + const FReservationInfo& R = Reservations[i]; + if (R.Texture.IsValid() && R.Texture.Get() == Tex) + { + UE_LOG(LogStevesUEHelpers, Verbose, TEXT("FStevesTextureRenderTargetPool: Released texture reservation on %s"), *Tex->GetName()); + UnreservedTextures.Add(R.Key, Tex); + Reservations.RemoveAtSwap(i); + ReservedTextures.Remove(Tex); + + return; + } + } + + UE_LOG(LogStevesUEHelpers, Warning, TEXT("FStevesTextureRenderTargetPool: Attempted to release a reservation on %s that was not found"), *Tex->GetName()); + +} + +FStevesTextureRenderTargetPool::~FStevesTextureRenderTargetPool() +{ + DrainPool(true); +} + +void FStevesTextureRenderTargetPool::AddReferencedObjects(FReferenceCollector& Collector) +{ + // We need to hold on to the texture references + Collector.AddReferencedObjects(ReservedTextures); + Collector.AddReferencedObjects(UnreservedTextures); +} + +#if ENGINE_MAJOR_VERSION >= 5 +FString FStevesTextureRenderTargetPool::GetReferencerName() const +{ + return "FStevesTextureRenderTargetPool"; +} +#endif + +FStevesTextureRenderTargetReservationPtr FStevesTextureRenderTargetPool::ReserveTexture(FIntPoint Size, + ETextureRenderTargetFormat Format, const UObject* Owner) +{ + const FTextureKey Key {Size, Format}; + UTextureRenderTarget2D* Tex = nullptr; + if (auto Pooled = UnreservedTextures.Find(Key)) + { + Tex = *Pooled; + UnreservedTextures.RemoveSingle(Key, Tex); + UE_LOG(LogStevesUEHelpers, Verbose, TEXT("FStevesTextureRenderTargetPool: Re-used pooled texture %s"), *Tex->GetName()); + } + else if (Size.X > 0 && Size.Y > 0) + { + // No existing texture, so create + // Texture owner should be a valid UObject that will determine lifespan + UObject* TextureOwner = PoolOwner.IsValid() ? PoolOwner.Get() : GetTransientPackage(); + Tex = NewObject(TextureOwner); + Tex->RenderTargetFormat = Format; + Tex->InitAutoFormat(Size.X, Size.Y); + Tex->UpdateResourceImmediate(true); + + UE_LOG(LogStevesUEHelpers, Verbose, TEXT("FStevesTextureRenderTargetPool: Created new texture %s"), *Tex->GetName()); + } + + // Record reservation + Reservations.Add(FReservationInfo(Key, Owner, Tex)); + + // Reservation doesn't keep the texture alive; if caller doesn't hold a strong pointer to it, it'll be destroyed + // So we need to hold it ourselves + ReservedTextures.Add(Tex); + + return MakeShared(Tex, this->AsShared(), Owner); +} + +void FStevesTextureRenderTargetPool::RevokeReservations(const UObject* ForOwner) +{ + for (int i = 0; i < Reservations.Num(); ++i) + { + const FReservationInfo& R = Reservations[i]; + if (!ForOwner || R.Owner == ForOwner) + { + if (R.Texture.IsValid()) + { + UE_LOG(LogStevesUEHelpers, Verbose, TEXT("FStevesTextureRenderTargetPool: Revoked texture reservation on %s"), *R.Texture->GetName()); + UnreservedTextures.Add(R.Key, R.Texture.Get()); + ReservedTextures.Remove(R.Texture.Get()); + } + // Can't use RemoveAtSwap because it'll change order + Reservations.RemoveAt(i); + // Adjust index backwards to compensate + --i; + } + } +} + +void FStevesTextureRenderTargetPool::DrainPool(bool bForceAndRevokeReservations) +{ + if (bForceAndRevokeReservations) + RevokeReservations(); + + for (auto& TexPair : UnreservedTextures) + { + UKismetRenderingLibrary::ReleaseRenderTarget2D(TexPair.Value); + } + UnreservedTextures.Empty(); + ReservedTextures.Empty(); + +} diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUEHelpers.cpp b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUEHelpers.cpp new file mode 100644 index 00000000..be7f7f37 --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUEHelpers.cpp @@ -0,0 +1,24 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +#include "StevesUEHelpers.h" + +#define LOCTEXT_NAMESPACE "FStevesUEHelpers" + +DEFINE_LOG_CATEGORY(LogStevesUEHelpers) + +void FStevesUEHelpers::StartupModule() +{ + // This code will execute after your module is loaded into memory; the exact timing is specified in the .uplugin file per-module + UE_LOG(LogStevesUEHelpers, Log, TEXT("Steve's UE Helpers Module Started")) +} + +void FStevesUEHelpers::ShutdownModule() +{ + // This function may be called during shutdown to clean up your module. For modules that support dynamic reloading, + // we call this function before unloading the module. + UE_LOG(LogStevesUEHelpers, Log, TEXT("Steve's UE Helpers Module Stopped")) +} + +#undef LOCTEXT_NAMESPACE + +IMPLEMENT_MODULE(FStevesUEHelpers, StevesUEHelpers) diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/FocusSystem.cpp b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/FocusSystem.cpp new file mode 100644 index 00000000..db0a6020 --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/FocusSystem.cpp @@ -0,0 +1,78 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +#include "StevesUI/FocusSystem.h" +#include "StevesUI/FocusableUserWidget.h" + +DEFINE_LOG_CATEGORY(LogFocusSystem) + +TWeakObjectPtr FFocusSystem::GetHighestFocusPriority() +{ + int Highest = -999; + TWeakObjectPtr Ret; + + for (auto && S : ActiveAutoFocusWidgets) + { + if (S.IsValid() && S->IsRequestingFocus() && S->GetAutomaticFocusPriority() > Highest) + { + Highest = S->GetAutomaticFocusPriority(); + Ret = S; + } + } + + return Ret; +} + +void FFocusSystem::FocusableWidgetConstructed(UFocusableUserWidget* Widget) +{ + UE_LOG(LogFocusSystem, Display, TEXT("FocusableUserWidget %s opened"), *Widget->GetName()); + // check to make sure we never dupe, shouldn't normally be a problem + // but let's just be safe, there will never be that many + bool bPresent = false; + for (auto && S : ActiveAutoFocusWidgets) + { + if (S.Get() == Widget) + { + bPresent = true; + break; + } + } + if (!bPresent) + ActiveAutoFocusWidgets.Add(Widget); + + if (Widget->IsRequestingFocus()) + { + auto Highest = GetHighestFocusPriority(); + if (!Highest.IsValid() || Highest->GetAutomaticFocusPriority() <= Widget->GetAutomaticFocusPriority()) + { + // give new stack the focus if it's equal or higher priority than anything else + UE_LOG(LogFocusSystem, Display, TEXT("Giving focus to %s"), *Widget->GetName()); + Widget->TakeFocusIfDesired(); + } + } +} + +void FFocusSystem::FocusableWidgetDestructed(UFocusableUserWidget* Widget) +{ + UE_LOG(LogFocusSystem, Display, TEXT("FocusableUserWidget %s closed"), *Widget->GetName()); + + for (int i = 0; i < ActiveAutoFocusWidgets.Num(); ++i) + { + if (ActiveAutoFocusWidgets[i].Get() == Widget) + { + ActiveAutoFocusWidgets.RemoveAt(i); + break; + } + } + + // if the menu closing had focus, give it to the highest remaining stack + if (Widget->HasFocusedDescendants()) + { + auto Highest = GetHighestFocusPriority(); + // Make sure player controller is valid too, this could be on shutdown + if (Highest.IsValid() && Highest->GetOwningPlayer()) + { + UE_LOG(LogFocusSystem, Display, TEXT("Giving focus to %s"), *Highest->GetName()); + Highest->TakeFocusIfDesired(); + } + } +} diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/FocusableButton.cpp b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/FocusableButton.cpp new file mode 100644 index 00000000..48451b8f --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/FocusableButton.cpp @@ -0,0 +1,160 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +#include "StevesUI/FocusableButton.h" +#include "StevesUI/SFocusableButton.h" +#include "Components/ButtonSlot.h" +#include "Framework/Application/NavigationConfig.h" +#include "Framework/Application/SlateApplication.h" +#include "Runtime/Launch/Resources/Version.h" + + +UFocusableButton::UFocusableButton(const FObjectInitializer& ObjectInitializer) + : Super(ObjectInitializer) +{ +} + +TSharedRef UFocusableButton::RebuildWidget() +{ + MyButton = SNew(SFocusableButton) + // This part is standard button behaviour + .OnClicked(BIND_UOBJECT_DELEGATE(FOnClicked, SlateHandleClicked)) + .OnPressed(BIND_UOBJECT_DELEGATE(FSimpleDelegate, SlateHandlePressed)) + .OnReleased(BIND_UOBJECT_DELEGATE(FSimpleDelegate, SlateHandleReleased)) + .OnHovered_UObject( this, &ThisClass::SlateHandleHovered ) + .OnUnhovered_UObject( this, &ThisClass::SlateHandleUnhovered ) + .ButtonStyle(&GetStyle()) + .ClickMethod(GetClickMethod()) + .TouchMethod(GetTouchMethod()) + .PressMethod(GetPressMethod()) + .IsFocusable(GetIsFocusable()) + // Our new events + .OnFocusReceived(BIND_UOBJECT_DELEGATE(FSimpleDelegate, SlateHandleFocusReceived)) + .OnFocusLost(BIND_UOBJECT_DELEGATE(FSimpleDelegate, SlateHandleFocusLost)) + ; + + if ( GetChildrenCount() > 0 ) + { + Cast(GetContentSlot())->BuildSlot(MyButton.ToSharedRef()); + } + + RefreshFocussedStyle(); + + return MyButton.ToSharedRef(); +} + +void UFocusableButton::RefreshFocussedStyle_Implementation() +{ + // Copy Widget style but make normal same as hovered + FocussedStyle = GetStyle(); + FocussedStyle.Normal = FocussedStyle.Hovered; +} + +void UFocusableButton::SimulatePress() +{ + if (MyButton) + { + // In order to get the visual change we need to call SButton::Press() and SButton::Release(), but they're both private + // The only public method we can use to simulate the click properly is OnKeyDown/Up or OnMouseButtonDown/Up + // Use Virtual_Accept since that is general + FKeyEvent KeyEvent( +#if ENGINE_MAJOR_VERSION == 5 && ENGINE_MINOR_VERSION >= 7 + EKeys::Virtual_Gamepad_Accept.GetVirtualKey(), +#else + EKeys::Virtual_Accept, +#endif + FModifierKeysState(), 0, false, 0, 0); + MyButton->OnKeyDown(MyButton->GetCachedGeometry(), KeyEvent); + } +} + +void UFocusableButton::SimulateRelease() +{ + if (MyButton) + { + // In order to get the visual change we need to call SButton::Press() and SButton::Release(), but they're both private + // The only public method we can use to simulate the click properly is OnKeyDown/Up or OnMouseButtonDown/Up + // Use Virtual_Accept since that is general + FKeyEvent KeyEvent( +#if ENGINE_MAJOR_VERSION == 5 && ENGINE_MINOR_VERSION >= 7 + EKeys::Virtual_Gamepad_Accept.GetVirtualKey(), +#else + EKeys::Virtual_Accept, +#endif + FModifierKeysState(), 0, false, 0, 0); + MyButton->OnKeyUp(MyButton->GetCachedGeometry(), KeyEvent); + + } +} + +void UFocusableButton::SlateHandleFocusReceived() +{ + ApplyFocusStyle(); + OnFocusReceived.Broadcast(); +} + +void UFocusableButton::SlateHandleFocusLost() +{ + UndoFocusStyle(); + OnFocusLost.Broadcast(); +} + + +void UFocusableButton::ApplyFocusStyle() +{ + if (MyButton.IsValid() && bUseHoverStyleWhenFocussed) + { + MyButton->SetButtonStyle(&FocussedStyle); + } +} + +void UFocusableButton::UndoFocusStyle() +{ + if (MyButton.IsValid()) + { + MyButton->SetButtonStyle(&GetStyle()); + } +} + +void UFocusableButton::SlateHandleHovered() +{ + if (bTakeFocusOnHover) + { + SetFocus(); + } + OnHovered.Broadcast(); +} + +void UFocusableButton::SlateHandleUnhovered() +{ + if (bLoseFocusOnUnhover) + { + Unfocus(); + } + OnUnhovered.Broadcast(); +} + +void UFocusableButton::Unfocus() const +{ + APlayerController* OwningPlayer = GetOwningPlayer(); + if (OwningPlayer == nullptr || !OwningPlayer->IsLocalController() || OwningPlayer->Player == + nullptr) + { + return; + } + if (ULocalPlayer* LocalPlayer = OwningPlayer->GetLocalPlayer()) + { + TOptional UserIndex = FSlateApplication::Get().GetUserIndexForController( + LocalPlayer->GetControllerId()); + if (UserIndex.IsSet()) + { + TSharedPtr SafeWidget = GetCachedWidget(); + if (SafeWidget.IsValid()) + { + if (SafeWidget->HasUserFocus(UserIndex.GetValue())) + { + FSlateApplication::Get().ClearUserFocus(UserIndex.GetValue()); + } + } + } + } +} diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/FocusableCheckBox.cpp b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/FocusableCheckBox.cpp new file mode 100644 index 00000000..4ab074c9 --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/FocusableCheckBox.cpp @@ -0,0 +1,133 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +#include "StevesUI/FocusableCheckBox.h" +#include "StevesUI/SFocusableCheckBox.h" +#include "Runtime/Launch/Resources/Version.h" +#include "Framework/Application/SlateApplication.h" + + +TSharedRef UFocusableCheckBox::RebuildWidget() +{ + MyCheckbox = SNew(SFocusableCheckBox) + .OnCheckStateChanged( BIND_UOBJECT_DELEGATE(FOnCheckStateChanged, SlateOnCheckStateChangedCallback) ) +#if ENGINE_MAJOR_VERSION == 5 && ENGINE_MINOR_VERSION > 0 + .Style(&GetWidgetStyle()) + .ClickMethod(GetClickMethod()) + .TouchMethod(GetTouchMethod()) + .PressMethod(GetPressMethod()) +#else + .Style(&WidgetStyle) + .ClickMethod(ClickMethod) + .TouchMethod(TouchMethod) + .PressMethod(PressMethod) +#endif + .HAlign( HorizontalAlignment ) + .IsFocusable(GetIsFocusable()) + // Our new events + .OnHovered(BIND_UOBJECT_DELEGATE(FSimpleDelegate, SlateHandleHovered)) + .OnUnhovered(BIND_UOBJECT_DELEGATE(FSimpleDelegate, SlateHandleUnhovered)) + .OnFocusReceived(BIND_UOBJECT_DELEGATE(FSimpleDelegate, SlateHandleFocusReceived)) + .OnFocusLost(BIND_UOBJECT_DELEGATE(FSimpleDelegate, SlateHandleFocusLost)) + ; + + if ( GetChildrenCount() > 0 ) + { + MyCheckbox->SetContent(GetContentSlot()->Content ? GetContentSlot()->Content->TakeWidget() : SNullWidget::NullWidget); + } + + RefreshFocussedStyle(); + + + return MyCheckbox.ToSharedRef(); + +} + +void UFocusableCheckBox::RefreshFocussedStyle_Implementation() +{ + // Copy Widget style but make normal same as hovered +#if ENGINE_MAJOR_VERSION == 5 && ENGINE_MINOR_VERSION > 0 + FocussedStyle = GetWidgetStyle(); +#else + FocussedStyle = WidgetStyle; +#endif + FocussedStyle.UncheckedImage = FocussedStyle.UncheckedHoveredImage; + FocussedStyle.CheckedImage = FocussedStyle.CheckedHoveredImage; + FocussedStyle.UndeterminedImage = FocussedStyle.UndeterminedHoveredImage; +} + +void UFocusableCheckBox::SlateHandleFocusReceived() +{ + ApplyFocusStyle(); + OnFocusReceived.Broadcast(); +} + +void UFocusableCheckBox::SlateHandleFocusLost() +{ + UndoFocusStyle(); + OnFocusLost.Broadcast(); +} + + +void UFocusableCheckBox::ApplyFocusStyle() +{ + if (MyCheckbox.IsValid() && bUseHoverStyleWhenFocussed) + { + MyCheckbox->SetStyle(&FocussedStyle); + } +} + +void UFocusableCheckBox::UndoFocusStyle() +{ + if (MyCheckbox.IsValid()) + { +#if ENGINE_MAJOR_VERSION == 5 && ENGINE_MINOR_VERSION > 0 + MyCheckbox->SetStyle(&GetWidgetStyle()); +#else + MyCheckbox->SetStyle(&WidgetStyle); +#endif + } +} + +void UFocusableCheckBox::SlateHandleHovered() +{ + if (bTakeFocusOnHover) + { + SetFocus(); + } + OnHovered.Broadcast(); +} + +void UFocusableCheckBox::SlateHandleUnhovered() +{ + if (bLoseFocusOnUnhover) + { + Unfocus(); + } + OnUnhovered.Broadcast(); +} + +void UFocusableCheckBox::Unfocus() const +{ + APlayerController* OwningPlayer = GetOwningPlayer(); + if (OwningPlayer == nullptr || !OwningPlayer->IsLocalController() || OwningPlayer->Player == + nullptr) + { + return; + } + if (ULocalPlayer* LocalPlayer = OwningPlayer->GetLocalPlayer()) + { + TOptional UserIndex = FSlateApplication::Get().GetUserIndexForController( + LocalPlayer->GetControllerId()); + if (UserIndex.IsSet()) + { + TSharedPtr SafeWidget = GetCachedWidget(); + if (SafeWidget.IsValid()) + { + if (SafeWidget->HasUserFocus(UserIndex.GetValue())) + { + FSlateApplication::Get().ClearUserFocus(UserIndex.GetValue()); + } + } + } + } +} diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/FocusablePanel.cpp b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/FocusablePanel.cpp new file mode 100644 index 00000000..8facb3b3 --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/FocusablePanel.cpp @@ -0,0 +1,100 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +#include "StevesUI/FocusablePanel.h" + + +#include "StevesUI.h" +#include "Blueprint/WidgetTree.h" +#include "Framework/Application/SlateApplication.h" + +void UFocusablePanel::NativeConstruct() +{ + Super::NativeConstruct(); + + // Find focus widget + + InitialFocusWidget = GetInitialFocusWidget(); +#if WITH_EDITOR + if (!InitialFocusWidget.IsValid()) + { + UE_LOG(LogStevesUI, Error, TEXT("Initial focus widget `%s` not found on %s, focus will be lost"), *InitialFocusWidgetName.ToString(), *GetName()) + } +#endif + +} + + + + + +void UFocusablePanel::NativeDestruct() +{ + Super::NativeDestruct(); + + InitialFocusWidget.Reset(); +} + +bool UFocusablePanel::SetFocusToInitialWidget() +{ + if (!InitialFocusWidget.IsValid()) + { + // if not set, attempt to get + InitialFocusWidget = GetInitialFocusWidget(); + } + if (InitialFocusWidget.IsValid()) + { + SetWidgetFocusProperly(InitialFocusWidget.Get()); + return true; + } + return false; +} + +UWidget* UFocusablePanel::GetInitialFocusWidget_Implementation() +{ + if (!InitialFocusWidgetName.IsNone()) + { + return WidgetTree->FindWidget(InitialFocusWidgetName); + } + return nullptr; +} + + +void UFocusablePanel::SetInitialFocusWidget(UWidget* NewInitialFocus) +{ + if (NewInitialFocus) + { + InitialFocusWidgetName = NewInitialFocus->GetFName(); + } +} + +bool UFocusablePanel::RestorePreviousFocus() const +{ + if (PreviousFocusWidget.IsValid()) + { + SetWidgetFocusProperly(PreviousFocusWidget.Get()); + return true; + } + return false; +} + +bool UFocusablePanel::SavePreviousFocus() +{ + const auto SW = FSlateApplication::Get().GetUserFocusedWidget(0); + if (SW) + { + PreviousFocusWidget = FindWidgetFromSlate(SW.Get(), this); + return true; + } + else + { + PreviousFocusWidget.Reset(); + return false; + } +} + +void UFocusablePanel::SetFocusProperly_Implementation() +{ + if (!RestorePreviousFocus()) + SetFocusToInitialWidget(); +} + diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/FocusableSlider.cpp b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/FocusableSlider.cpp new file mode 100644 index 00000000..13f85df6 --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/FocusableSlider.cpp @@ -0,0 +1,112 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license + +#include "StevesUI/FocusableSlider.h" +#include "Framework/Application/SlateApplication.h" +#include "Engine/LocalPlayer.h" + +#include "SFocusableSlider.h" + +TSharedRef UFocusableSlider::RebuildWidget() +{ + MySlider = SNew(SFocusableSlider) + .Style(&GetWidgetStyle()) + .OnMouseCaptureBegin(BIND_UOBJECT_DELEGATE(FSimpleDelegate, HandleOnMouseCaptureBegin)) + .OnMouseCaptureEnd(BIND_UOBJECT_DELEGATE(FSimpleDelegate, HandleOnMouseCaptureEnd)) + .OnControllerCaptureBegin(BIND_UOBJECT_DELEGATE(FSimpleDelegate, HandleOnControllerCaptureBegin)) + .OnControllerCaptureEnd(BIND_UOBJECT_DELEGATE(FSimpleDelegate, HandleOnControllerCaptureEnd)) + .OnValueChanged(BIND_UOBJECT_DELEGATE(FOnFloatValueChanged, HandleOnValueChanged)) + // Our new events + .OnHovered(BIND_UOBJECT_DELEGATE(FSimpleDelegate, SlateHandleHovered)) + .OnUnhovered(BIND_UOBJECT_DELEGATE(FSimpleDelegate, SlateHandleUnhovered)) + .OnFocusReceived(BIND_UOBJECT_DELEGATE(FSimpleDelegate, SlateHandleFocusReceived)) + .OnFocusLost(BIND_UOBJECT_DELEGATE(FSimpleDelegate, SlateHandleFocusLost)) + ; + + RefreshFocussedStyle(); + + + return MySlider.ToSharedRef(); + +} + +void UFocusableSlider::RefreshFocussedStyle_Implementation() +{ + // Copy Widget style but make normal same as hovered + FocussedStyle = GetWidgetStyle(); + FocussedStyle.NormalBarImage = FocussedStyle.HoveredBarImage; + FocussedStyle.NormalThumbImage = FocussedStyle.HoveredThumbImage; +} + +void UFocusableSlider::SlateHandleFocusReceived() +{ + ApplyFocusStyle(); + OnFocusReceived.Broadcast(); +} + +void UFocusableSlider::SlateHandleFocusLost() +{ + UndoFocusStyle(); + OnFocusLost.Broadcast(); +} + + +void UFocusableSlider::ApplyFocusStyle() +{ + if (MySlider.IsValid() && bUseHoverStyleWhenFocussed) + { + MySlider->SetStyle(&FocussedStyle); + } +} + +void UFocusableSlider::UndoFocusStyle() +{ + if (MySlider.IsValid()) + { + MySlider->SetStyle(&GetWidgetStyle()); + } +} + +void UFocusableSlider::SlateHandleHovered() +{ + if (bTakeFocusOnHover) + { + SetFocus(); + } + OnHovered.Broadcast(); +} + +void UFocusableSlider::SlateHandleUnhovered() +{ + if (bLoseFocusOnUnhover) + { + Unfocus(); + } + OnUnhovered.Broadcast(); +} + +void UFocusableSlider::Unfocus() const +{ + APlayerController* OwningPlayer = GetOwningPlayer(); + if (OwningPlayer == nullptr || !OwningPlayer->IsLocalController() || OwningPlayer->Player == + nullptr) + { + return; + } + if (ULocalPlayer* LocalPlayer = OwningPlayer->GetLocalPlayer()) + { + TOptional UserIndex = FSlateApplication::Get().GetUserIndexForController( + LocalPlayer->GetControllerId()); + if (UserIndex.IsSet()) + { + TSharedPtr SafeWidget = GetCachedWidget(); + if (SafeWidget.IsValid()) + { + if (SafeWidget->HasUserFocus(UserIndex.GetValue())) + { + FSlateApplication::Get().ClearUserFocus(UserIndex.GetValue()); + } + } + } + } +} diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/FocusableUserWidget.cpp b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/FocusableUserWidget.cpp new file mode 100644 index 00000000..204b8700 --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/FocusableUserWidget.cpp @@ -0,0 +1,68 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +#include "StevesUI/FocusableUserWidget.h" + +#include "StevesUEHelpers.h" + +void UFocusableUserWidget::SetFocusProperly_Implementation() +{ + // Default is to call normal + SetFocus(); +} + + +bool UFocusableUserWidget::IsRequestingFocus_Implementation() const +{ + // Subclasses can override this + return bEnableAutomaticFocus; +} + +bool UFocusableUserWidget::TakeFocusIfDesired_Implementation() +{ + auto GS = GetStevesGameSubsystem(GetWorld()); + if (IsRequestingFocus() && + GS && (GS->GetLastInputModeUsed() != EInputMode::Gamepad || GS->GetLastInputModeUsed() != EInputMode::Keyboard)) + { + SetFocusProperly(); + return true; + } + return false; +} + +FReply UFocusableUserWidget::NativeOnFocusReceived(const FGeometry& InGeometry, const FFocusEvent& InFocusEvent) +{ + FReply Reply = Super::NativeOnFocusReceived(InGeometry, InFocusEvent); + + if (!Reply.IsEventHandled()) + { + SetFocusProperly(); + return Reply.Handled(); + } + + return Reply; +} + +void UFocusableUserWidget::NativeConstruct() +{ + Super::NativeConstruct(); + + if (bEnableAutomaticFocus) + { + auto GS = GetStevesGameSubsystem(GetWorld()); + if (GS) + GS->GetFocusSystem()->FocusableWidgetConstructed(this); + } +} + +void UFocusableUserWidget::NativeDestruct() +{ + Super::NativeDestruct(); + + if (bEnableAutomaticFocus) + { + auto GS = GetStevesGameSubsystem(GetWorld()); + if (GS) + GS->GetFocusSystem()->FocusableWidgetDestructed(this); + } + +} diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/InputImage.cpp b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/InputImage.cpp new file mode 100644 index 00000000..5838778c --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/InputImage.cpp @@ -0,0 +1,229 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +#include "StevesUI/InputImage.h" + +#include "EnhancedInputSubsystems.h" +#include "StevesGameSubsystem.h" +#include "StevesUEHelpers.h" +#include "Blueprint/WidgetTree.h" +#include "InputAction.h" + +TSharedRef UInputImage::RebuildWidget() +{ + auto Ret = Super::RebuildWidget(); + + auto GS = GetStevesGameSubsystem(GetWorld()); + if (GS && !bSubbedToInputEvents) + { + bSubbedToInputEvents = true; + GS->OnInputModeChanged.AddUniqueDynamic(this, &UInputImage::OnInputModeChanged); + GS->OnButtonInputModeChanged.AddUniqueDynamic(this, &UInputImage::OnInputModeChanged); + GS->OnAxisInputModeChanged.AddUniqueDynamic(this, &UInputImage::OnInputModeChanged); + if (InputAction) + { + // Enhanced input now has a mappings rebuilt hook which we can use (Since July 2022) + if (UEnhancedInputLocalPlayerSubsystem* Subsystem = ULocalPlayer::GetSubsystem(GetOwningLocalPlayer())) + { + Subsystem->ControlMappingsRebuiltDelegate.AddUniqueDynamic(this, &UInputImage::OnEnhancedInputMappingsChanged); + } + } + + } + + UpdateImage(); + + return Ret; +} + +void UInputImage::OnInputModeChanged(int ChangedPlayerIdx, EInputMode InputMode) +{ + if (ChangedPlayerIdx == PlayerIndex) + { + // Delay update, in case multiple received in short succession + MarkImageDirty(); + // auto GS = GetStevesGameSubsystem(GetWorld()); + // UE_LOG(LogTemp, Warning, TEXT("Updating image for input mode change: %s Button device: %s"), + // *UEnum::GetValueAsString(InputMode), + // *UEnum::GetValueAsString(GS->GetLastInputButtonPressed(ChangedPlayerIdx))); + } +} + +void UInputImage::OnEnhancedInputMappingsChanged() +{ + MarkImageDirty(); +} + +void UInputImage::SetCustomTheme(UUiTheme* Theme) +{ + CustomTheme = Theme; + UpdateImage(); +} + +void UInputImage::BeginDestroy() +{ + Super::BeginDestroy(); + + auto GS = GetStevesGameSubsystem(GetWorld()); + if (GS) + { + GS->OnInputModeChanged.RemoveAll(this); + GS->OnButtonInputModeChanged.RemoveAll(this); + GS->OnAxisInputModeChanged.RemoveAll(this); + } +} + +void UInputImage::SetVisibility(ESlateVisibility InVisibility) +{ + Super::SetVisibility(InVisibility); + + switch(InVisibility) + { + case ESlateVisibility::Collapsed: + case ESlateVisibility::Hidden: + break; + default: + case ESlateVisibility::Visible: + case ESlateVisibility::HitTestInvisible: + case ESlateVisibility::SelfHitTestInvisible: + // Make sure we update when our visibility is changed + UpdateImage(); + break; + }; +} + +void UInputImage::SetFromAction(FName Name) +{ + BindingType = EInputBindingType::Action; + ActionOrAxisName = Name; + UpdateImage(); +} + +void UInputImage::SetFromAxis(FName Name) +{ + BindingType = EInputBindingType::Axis; + ActionOrAxisName = Name; + UpdateImage(); +} + +void UInputImage::SetFromKey(FKey K) +{ + BindingType = EInputBindingType::Key; + Key = K; + UpdateImage(); +} + +void UInputImage::SetFromInputAction(UInputAction* Action) +{ + BindingType = EInputBindingType::EnhancedInputAction; + InputAction = Action; + UpdateImage(); +} + +void UInputImage::UpdateImage() +{ + auto GS = GetStevesGameSubsystem(GetWorld()); + if (GS) + { + UPaperSprite* Sprite = nullptr; + if (BindingType == EInputBindingType::EnhancedInputAction && !InputAction.IsNull()) + { + if (auto IA = InputAction.LoadSynchronous()) + { + Sprite = GS->GetInputImageSpriteFromEnhancedInputAction(IA, DevicePreference, PlayerIndex, GetOwningPlayer(), CustomTheme); + } + } + else + { + Sprite = GS->GetInputImageSprite(BindingType, ActionOrAxisName, Key, DevicePreference, PlayerIndex, CustomTheme); + } + + if (Sprite) + { + if (bHiddenBecauseBlank || bOverrideHiddenState) + { + // Use Internal so as not to recurse back here + SetVisibilityInternal(OldVisibility); + bHiddenBecauseBlank = false; + } + // Match size is needed incase size has changed + // Need to make it update region in case inside a scale box or something else that needs to adjust + SetBrushFromAtlasInterface(Sprite, true); + } + else + { + if (IsVisible()) + { + bHiddenBecauseBlank = true; + OldVisibility = GetVisibility(); + // Use Internal so as not to recurse back here + SetVisibilityInternal(ESlateVisibility::Hidden); + } + } + } + bIsDirty = false; + DelayUpdate = 0; +} + +void UInputImage::MarkImageDirty() +{ + if (UpdateDelay > 0) + { + bIsDirty = true; + DelayUpdate = UpdateDelay; + } + else + { + UpdateImage(); + } +} + +// Tickables +// We need a tick rather than FTimer because timers won't run when game is paused, and UIs are useful while paused! +ETickableTickType UInputImage::GetTickableTickType() const +{ + return ETickableTickType::Conditional; +} + +void UInputImage::FinishDestroy() +{ + + // Enhanced input now has a mappings rebuilt hook which we can use (Since July 2022) + if (UEnhancedInputLocalPlayerSubsystem* Subsystem = ULocalPlayer::GetSubsystem(GetOwningLocalPlayer())) + { + Subsystem->ControlMappingsRebuiltDelegate.RemoveAll(this); + } + + Super::FinishDestroy(); +} + +bool UInputImage::IsTickableWhenPaused() const +{ + // UIs need to update while game is paused + return true; +} +bool UInputImage::IsTickableInEditor() const +{ + return false; +} + +bool UInputImage::IsTickable() const +{ + // Only need to tick while dirty + return bIsDirty; +} + +void UInputImage::Tick(float DeltaTime) +{ + DelayUpdate -= DeltaTime; + if (DelayUpdate <= 0) + { + UpdateImage(); + } +} + +TStatId UInputImage::GetStatId() const +{ + RETURN_QUICK_DECLARE_CYCLE_STAT( UInputImage, STATGROUP_Tickables ); +} + +// Tickables diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/MenuBase.cpp b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/MenuBase.cpp new file mode 100644 index 00000000..59ed936e --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/MenuBase.cpp @@ -0,0 +1,190 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +#include "StevesUI/MenuBase.h" + +#include "StevesUI.h" +#include "StevesGameSubsystem.h" +#include "StevesUEHelpers.h" +#include "StevesUI/MenuStack.h" +#include "Components/ContentWidget.h" +#include "Kismet/GameplayStatics.h" + +void UMenuBase::Close(bool bWasCancel) +{ + // Deliberately raise before parent so stack is always last in event sequence + OnClosed.Broadcast(this, bWasCancel); + if (ParentStack.IsValid()) + { + ParentStack->PopMenuIfTop(this, bWasCancel); + } else + { + // standalone mode + RemoveFromParent(); + PreviousFocusWidget.Reset(); + } + AfterClosed.Broadcast(this, bWasCancel); +} + +void UMenuBase::AddedToStack(UMenuStack* Parent) +{ + ParentStack = MakeWeakObjectPtr(Parent); + + Open(false); + OnAddedToStack(Parent); +} + + +void UMenuBase::InputModeChanged(EInputMode OldMode, EInputMode NewMode) +{ + if (OldMode == EInputMode::Mouse && + (NewMode == EInputMode::Gamepad || NewMode == EInputMode::Keyboard)) + { + if (bRequestFocus) + SetFocusProperly(); + } + else if ((OldMode == EInputMode::Gamepad || OldMode == EInputMode::Keyboard) && + NewMode == EInputMode::Mouse) + { + SavePreviousFocus(); + } +} + +bool UMenuBase::IsTopOfStack() const +{ + if (ParentStack.IsValid()) + { + return ParentStack->GetTopMenu() == this; + } + + return true; +} + +void UMenuBase::RemovedFromStack(UMenuStack* Parent) +{ + // This works whether embedded or not + RemoveFromParent(); + PreviousFocusWidget.Reset(); + OnRemovedFromStack(Parent); +} + +void UMenuBase::SupercededInStack(UMenuBase* ByMenu) +{ + SavePreviousFocus(); + + if (bEmbedInParentContainer) + { + if (bHideWhenSuperceded) + RemoveFromParent(); + } + else + { + if (bHideWhenSuperceded) + SetVisibility(ESlateVisibility::Collapsed); + } + OnSupercededInStack(ByMenu); +} + +void UMenuBase::RegainedFocusInStack() +{ + Open(true); + OnRegainedFocusInStack(); +} + +void UMenuBase::EmbedInParent() +{ + if (ParentStack.IsValid() && + ParentStack->MenuContainer != nullptr) + { + ParentStack->MenuContainer->SetContent(this); + } + else + UE_LOG(LogStevesUI, Error, TEXT("Cannot embed %s in parent, missing container"), *this->GetName()) + +} + +void UMenuBase::Open(bool bIsRegain) +{ + if (ParentStack.IsValid() && bEmbedInParentContainer && bHideWhenSuperceded) + EmbedInParent(); + else + AddToViewport(); + SetVisibility(bBlockClicks ? ESlateVisibility::Visible : ESlateVisibility::SelfHitTestInvisible); + + auto PC = GetOwningPlayer(); + switch (InputModeSetting) + { + default: + case EInputModeChange::DoNotChange: + break; + case EInputModeChange::UIOnly: + PC->SetInputMode(FInputModeUIOnly()); + break; + case EInputModeChange::GameAndUI: + PC->SetInputMode(FInputModeGameAndUI()); + break; + case EInputModeChange::GameOnly: + PC->SetInputMode(FInputModeGameOnly()); + break; + } + + switch (MousePointerVisibility) + { + default: + case EMousePointerVisibilityChange::DoNotChange: + break; + case EMousePointerVisibilityChange::Visible: + PC->bShowMouseCursor = true; + break; + case EMousePointerVisibilityChange::Hidden: + PC->bShowMouseCursor = false; + break; + } + + switch (GamePauseSetting) + { + default: + case EGamePauseChange::DoNotChange: + break; + case EGamePauseChange::Paused: + UGameplayStatics::SetGamePaused(GetWorld(), true); + break; + case EGamePauseChange::Unpaused: + UGameplayStatics::SetGamePaused(GetWorld(), false); + break; + } + + TakeFocusIfDesired(); + +} + +bool UMenuBase::RequestClose(bool bWasCancel) +{ + if (ValidateClose(bWasCancel)) + { + Close(bWasCancel); + return true; + } + return false; +} + +bool UMenuBase::ValidateClose_Implementation(bool bWasCancel) +{ + // Default always pass + return true; +} + +void UMenuBase::OnSupercededInStack_Implementation(UMenuBase* ByMenu) +{ +} + +void UMenuBase::OnRegainedFocusInStack_Implementation() +{ +} + +void UMenuBase::OnAddedToStack_Implementation(UMenuStack* Parent) +{ +} + +void UMenuBase::OnRemovedFromStack_Implementation(UMenuStack* Parent) +{ +} diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/MenuStack.cpp b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/MenuStack.cpp new file mode 100644 index 00000000..a03466cc --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/MenuStack.cpp @@ -0,0 +1,375 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +#include "StevesUI/MenuStack.h" + +#include "StevesUI.h" +#include "StevesGameSubsystem.h" +#include "StevesUEHelpers.h" +#include "StevesUI/MenuBase.h" +#include "Kismet/GameplayStatics.h" +#include "Engine/GameViewportClient.h" + +void UMenuStack::NativeConstruct() +{ + Super::NativeConstruct(); + + // We could technically do input change detection in our own input processor, but since this already does it nicely... + auto GS = GetStevesGameSubsystem(GetWorld()); + if (GS) + { + GS->OnInputModeChanged.AddDynamic(this, &UMenuStack::InputModeChanged); + LastInputMode = GS->GetLastInputModeUsed(); + + // Ensure that mouse is offscreen & hidden in gamepad mode + // I've seen mouse *occasionally* end up still in the middle of the screen in gamepad mode, + // causing confusing highlighting effects. I'm not sure if it's because of a resolution change or + // something else, but it needs to never be there. The stack is a decent place to do this + if (LastInputMode == EInputMode::Gamepad) + { + GS->MoveMouseOffScreen(true); + } + } +} + +void UMenuStack::NativeDestruct() +{ + Super::NativeDestruct(); + + auto GS = GetStevesGameSubsystem(GetWorld()); + if (GS) + GS->OnInputModeChanged.RemoveDynamic(this, &UMenuStack::InputModeChanged); + +} + + +void UMenuStack::SavePreviousInputMousePauseState() +{ + auto PC = GetOwningPlayer(); + UGameViewportClient* GVC = GetWorld()->GetGameViewport(); + + if (GVC->IgnoreInput()) + { + PreviousInputMode = EInputModeChange::UIOnly; + } + else + { + +#if (ENGINE_MAJOR_VERSION == 4 && ENGINE_MINOR_VERSION >= 26) || ENGINE_MAJOR_VERSION >= 5 + const auto CaptureMode = GVC->GetMouseCaptureMode(); +#else + const auto CaptureMode = GVC->CaptureMouseOnClick(); +#endif + + // Game-only mode captures permanently, that seems to be the best way to detect + if (CaptureMode == EMouseCaptureMode::CapturePermanently || + CaptureMode == EMouseCaptureMode::CapturePermanently_IncludingInitialMouseDown) + { + PreviousInputMode = EInputModeChange::GameOnly; + } + else + { + PreviousInputMode = EInputModeChange::GameAndUI; + } + } + PreviousMouseVisibility = PC->bShowMouseCursor ? EMousePointerVisibilityChange::Visible : EMousePointerVisibilityChange::Hidden; + PreviousPauseState = UGameplayStatics::IsGamePaused(GetWorld()) ? EGamePauseChange::Paused : EGamePauseChange::Unpaused; + + +} + +void UMenuStack::ApplyInputModeChange(EInputModeChange Change) const +{ + auto PC = GetOwningPlayer(); + + if (!PC) // possible during shutdown + return; + + switch (Change) + { + case EInputModeChange::DoNotChange: + break; + case EInputModeChange::UIOnly: + PC->SetInputMode(FInputModeUIOnly()); + break; + case EInputModeChange::GameAndUI: + PC->SetInputMode(FInputModeGameAndUI()); + break; + case EInputModeChange::GameOnly: + PC->SetInputMode(FInputModeGameOnly()); + break; + case EInputModeChange::Restore: + ApplyInputModeChange(PreviousInputMode); + break; + } + + if (Change != EInputModeChange::DoNotChange && bFlushOnInputModeChange) + { + PC->FlushPressedKeys(); + } +} + +void UMenuStack::ApplyMousePointerVisibility(EMousePointerVisibilityChange Change) const +{ + auto PC = GetOwningPlayer(); + + if (!PC) // possible during shutdown + return; + + switch (Change) + { + case EMousePointerVisibilityChange::DoNotChange: + break; + case EMousePointerVisibilityChange::Visible: + PC->bShowMouseCursor = true; + break; + case EMousePointerVisibilityChange::Hidden: + PC->bShowMouseCursor = false; + break; + case EMousePointerVisibilityChange::Restore: + ApplyMousePointerVisibility(PreviousMouseVisibility); + break; + } +} + +void UMenuStack::ApplyGamePauseChange(EGamePauseChange Change) const +{ + switch (Change) + { + case EGamePauseChange::DoNotChange: + break; + case EGamePauseChange::Paused: + UGameplayStatics::SetGamePaused(GetWorld(), true); + break; + case EGamePauseChange::Unpaused: + UGameplayStatics::SetGamePaused(GetWorld(), false); + break; + case EGamePauseChange::Restore: + ApplyGamePauseChange(PreviousPauseState); + break; + } + +} + +bool UMenuStack::HandleKeyDownEvent(const FKeyEvent& InKeyEvent) +{ + Super::HandleKeyDownEvent(InKeyEvent); + + // Hardcoding the Back / Exit menu navigation inputs because input mappings can't be trusted in UMG + // This is probably OK though, no-one redefines menu controls, right? + const FKey Key = InKeyEvent.GetKey(); + if (BackKeys.Contains(Key)) + { + // This is "Back" + // Request close but allow veto + if (Menus.Num() > 0) + { + auto Top = Menus.Last(); + Top->RequestClose(true); + } + return true; + } + else if (InstantCloseKeys.Contains(Key)) + { + // Shortcut to exit all menus + // Make sure we're open long enough + FDateTime CurrTime = FDateTime::Now(); + FTimespan Diff = CurrTime - TimeFirstOpen; + if (Diff.GetTicks() > (int64)(MinTimeOpen * ETimespan::TicksPerSecond)) + { + CloseAll(true); + return true; + } + + } + + return false; +} + + +void UMenuStack::InputModeChanged(int PlayerIndex, EInputMode NewMode) +{ + if (Menus.Num()) + { + Menus.Last()->InputModeChanged(LastInputMode, NewMode); + } + LastInputMode = NewMode; +} + +UMenuBase* UMenuStack::PushMenuByClass(TSubclassOf MenuClass) +{ + const FName Name = MakeUniqueObjectName(this->GetOuter(), MenuClass); + TSubclassOf BaseClass = MenuClass; + const auto NewMenu = Cast(CreateWidgetInstance(*this, BaseClass, Name)); + PushMenuByObject(NewMenu); + + return NewMenu; +} + +void UMenuStack::PushMenuByObject(UMenuBase* NewMenu) +{ + if (NewMenu) + { + if (Menus.Num() > 0) + { + auto Top = Menus.Last(); + Top->SupercededInStack(NewMenu); + // We keep this allocated, to restore later on back + } + Menus.Add(NewMenu); + bool IsFirstMenu = Menus.Num() == 1; + + if (IsFirstMenu) + BeforeFirstMenuOpened(); + + NewMenu->AddedToStack(this); + + if (IsFirstMenu) + FirstMenuOpened(); + } + else + { + UE_LOG(LogStevesUI, Error, TEXT("Tried to push a null menu onto the stack")); + + } +} + +void UMenuStack::PopMenu(bool bWasCancel) +{ + if (Menus.Num() > 0) + { + auto Top = Menus.Last(); + Top->RemovedFromStack(this); + Menus.Pop(); + // No explicit destroy in UMG, let GC do it + // we could try to re-use but probably not worth it vs complexity of reset for now + + if (Menus.Num() == 0) + { + LastMenuClosed(bWasCancel); + } + else + { + auto NewTop = Menus.Last(); + NewTop->RegainedFocusInStack(); + } + } + +} + +void UMenuStack::PopMenuIfTop(UMenuBase* UiMenuBase, bool bWasCancel) +{ + if (Menus.Num() > 0 && Menus.Last() == UiMenuBase) + { + PopMenu(bWasCancel); + } + else + { + UE_LOG(LogStevesUI, Error, TEXT("Tried to pop menu %s but it wasn't the top level"), *UiMenuBase->GetName()); + } +} + + +void UMenuStack::BeforeFirstMenuOpened() +{ + SavePreviousInputMousePauseState(); + + ApplyInputModeChange(InputModeSettingOnOpen); + ApplyMousePointerVisibility(MousePointerVisibilityOnOpen); + ApplyGamePauseChange(GamePauseSettingOnOpen); +} + +void UMenuStack::FirstMenuOpened() +{ + // Don't use world time (even real time) since map can change while open + TimeFirstOpen = FDateTime::Now(); + + if (!IsInViewport() && bAutoAddToViewport) + { + AddToViewport(); + } +} + +void UMenuStack::RemoveFromParent() +{ + if (Menus.Num() > 0) + { + CloseAll(false); + // LastMenuClosed will re-call this so don't duplicate + return; + } + + Super::RemoveFromParent(); + +} + +UMenuBase* UMenuStack::GetTopMenu() const +{ + if (Menus.Num() > 0) + { + return Menus.Top(); + } + return nullptr; +} + +UMenuBase* UMenuStack::GetPreviousMenu() const +{ + if (Menus.Num() > 1) + { + return Menus.Last(1); + } + return nullptr; +} + +UMenuStack::UMenuStack(): + LastInputMode(EInputMode::Unknown), + PreviousInputMode(EInputModeChange::GameOnly), + PreviousMouseVisibility(EMousePointerVisibilityChange::Visible), + PreviousPauseState(EGamePauseChange::Unpaused), + MenuContainer(nullptr) +{ + // Default to enabling automatic focus for menus (can still be overridden in serialized properties) + bEnableAutomaticFocus = true; +} + +void UMenuStack::LastMenuClosed(bool bWasCancel) +{ + if (bAutoRemoveFromViewport) + { + RemoveFromParent(); // this will do MenuSystem interaction + } + OnClosed.Broadcast(this, bWasCancel); + + ApplyInputModeChange(InputModeSettingOnClose); + ApplyMousePointerVisibility(MousePointerVisibilityOnClose); + ApplyGamePauseChange(GamePauseSettingOnClose); + +} + + +void UMenuStack::CloseAll(bool bWasCancel) +{ + // We don't go through normal pop sequence, this is a shot circuit + for (int i = Menus.Num() - 1; i >= 0; --i) + { + UMenuBase* Menu = Menus[i]; + if (IsValid(Menu)) + { + Menus[i]->RemovedFromStack(this); + } + } + Menus.Empty(); + LastMenuClosed(bWasCancel); +} + +bool UMenuStack::IsRequestingFocus_Implementation() const +{ + // Delegate to top menu + return Menus.Num() > 0 && Menus.Last()->IsRequestingFocus(); +} + +void UMenuStack::SetFocusProperly_Implementation() +{ + // Delegate to top menu + if (Menus.Num() > 0) + Menus.Last()->SetFocusProperly(); +} diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/MultiSubtitleVerticalbox.cpp b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/MultiSubtitleVerticalbox.cpp new file mode 100644 index 00000000..c5a1b5e1 --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/MultiSubtitleVerticalbox.cpp @@ -0,0 +1,183 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license + + +#include "StevesUI/MultiSubtitleVerticalbox.h" + +#include "SubtitleManager.h" +#include "TimerManager.h" +#include "Engine/Font.h" +#include "Kismet/GameplayStatics.h" + + +TSharedRef UMultiSubtitleVerticalbox::RebuildWidget() +{ + auto Ret = Super::RebuildWidget(); + + if (!bSubscribed) + { + if (auto SM = FSubtitleManager::GetSubtitleManager()) + { + SM->OnSetSubtitleText().AddUObject(this, &UMultiSubtitleVerticalbox::SetSubtitleText); + bSubscribed = true; + } + } + + if (!SubtitleUpdateTimer.IsValid() && GetWorld()) + { + GetWorld()->GetTimerManager().SetTimer( + SubtitleUpdateTimer, this, &UMultiSubtitleVerticalbox::UpdateSubtitles, 1, true); + } + + if (ConcurrentLines != TextBlocks.Num()) + { + // If there aren't enough textblocks in the vertical box, add some + while (TextBlocks.Num() < ConcurrentLines) + { + TextBlocks.Add(CreateTextBlock()); + } + + // If there are too many, remove them and mark them for cleanup + while (TextBlocks.Num() > ConcurrentLines) + { + TObjectPtr Last = TextBlocks.Pop(); + + if (Last != nullptr) + { + RemoveChild(Last); + } + } + } + + // Recreate the subtitles list if needed + if (ConcurrentLines != Subtitles.Num()) + { + Subtitles.Empty(); + Subtitles.Init({}, ConcurrentLines); + } + + return Ret; +} + + +void UMultiSubtitleVerticalbox::BeginDestroy() +{ + Super::BeginDestroy(); + + if (bSubscribed) + { + if (auto SM = FSubtitleManager::GetSubtitleManager()) + { + SM->OnSetSubtitleText().RemoveAll(this); + bSubscribed = false; + } + } + + // Clear the timer if it has been set up + if (SubtitleUpdateTimer.IsValid() && GetWorld()) + { + GetWorld()->GetTimerManager().ClearTimer(SubtitleUpdateTimer); + } +} + + +TObjectPtr UMultiSubtitleVerticalbox::CreateTextBlock() +{ + TObjectPtr NewBlock = NewObject(this); + + if (NewBlock != nullptr) + { + // Copy the style settings over + NewBlock->SetColorAndOpacity(ColorAndOpacity); + NewBlock->SetMinDesiredWidth(MinDesiredWidth); + NewBlock->SetFont(Font); + NewBlock->SetStrikeBrush(StrikeBrush); + NewBlock->SetShadowOffset(ShadowOffset); + NewBlock->SetShadowColorAndOpacity(ShadowColorAndOpacity); + + AddChildToVerticalBox(NewBlock); + + return NewBlock; + } + return nullptr; +} + + +void UMultiSubtitleVerticalbox::SetSubtitleText(const FText& InText) +{ + // When there is no sound playing that generates a subtitle, InText + // will be empty - let UpdateSubtitles() handle timing out + if (InText.IsEmptyOrWhitespace()) + return; + + bool bFoundMatch = false; + uint64 Now = FDateTime::UtcNow().GetTicks(); + + // Go through the displayed subtitle list looking for the current subtitle + // so its update time can be bumped if it exists in the list. + for (FSubtitleHistory& Entry : Subtitles) + { + if (Entry.Subtitle.EqualTo(InText)) + { + bFoundMatch = true; + Entry.LastUpdate = Now; + break; + } + } + + if (!bFoundMatch) + { + // Try to locate an entry in the list that isn't in use already, and + // update it to contain the current subtitle. + for (FSubtitleHistory& Entry : Subtitles) + { + if (Entry.Subtitle.IsEmpty()) + { + bFoundMatch = true; + Entry.Subtitle = InText; + Entry.LastUpdate = Now; + break; + } + } + + // Still not found a place to put the new subtitle? Replace the oldest + // subtitle in the list with the new one. + if (!bFoundMatch) + { + Subtitles.Sort(); + FSubtitleHistory& Entry = Subtitles.Last(); + + Entry.Subtitle = InText; + Entry.LastUpdate = Now; + } + + UpdateSubtitles(); + } +} + + +void UMultiSubtitleVerticalbox::UpdateSubtitles() +{ + uint64 Now = FDateTime::UtcNow().GetTicks(); + + // Handle timeout of subtitles that haven't been updated recently + for (FSubtitleHistory& Entry : Subtitles) + { + if (!Entry.Subtitle.IsEmpty() && + (Now - Entry.LastUpdate) > (MaximumAge * ETimespan::TicksPerMillisecond * 1000)) + { + Entry.Subtitle = FText::GetEmpty(); + } + } + + Subtitles.Sort(); + + // Update the list of textblocks with the subtitle texts + for (int i = 0; i < TextBlocks.Num(); ++i) + { + if (TextBlocks[i] != nullptr && i < Subtitles.Num()) + { + TextBlocks[i]->SetText(Subtitles[i].Subtitle); + } + } +} diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/NamedOptionWidgetBase.cpp b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/NamedOptionWidgetBase.cpp new file mode 100644 index 00000000..72a84c96 --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/NamedOptionWidgetBase.cpp @@ -0,0 +1,80 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license + + +#include "StevesUI/NamedOptionWidgetBase.h" + +#include "StevesUI.h" + +void UNamedOptionWidgetBase::ClearOptions() +{ + Super::ClearOptions(); + + NameIndex.Reset(); +} + +int UNamedOptionWidgetBase::AddNamedOption(FName Name, FText Option) +{ + int Idx = Super::AddOption(Option); + NameIndex.Add(Name, Idx); + return Idx; +} + +void UNamedOptionWidgetBase::SetOptions(const TArray& NewOptions, int NewSelectedIndex) +{ + Super::SetOptions(NewOptions, NewSelectedIndex); + + + UE_LOG(LogStevesUI, Warning, TEXT("Using SetOptions on a Named Option Widget loses the name lookup index, consider using SetNamedOptions instead")) + NameIndex.Reset(); +} + +void UNamedOptionWidgetBase::SetNamedOptions(const TMap& InOptions, + FName NewSelectedName) +{ + Options.Reset(); + NameIndex.Reset(); + for (const auto& Pair : InOptions) + { + const int Idx = Options.Add(Pair.Value); + NameIndex.Add(Pair.Key, Idx); + } + SetSelectedName(NewSelectedName); +} + +FName UNamedOptionWidgetBase::GetSelectedName() const +{ + return SelectedName; +} + +int UNamedOptionWidgetBase::GetIndexForName(FName Name) +{ + if (auto pIdx = NameIndex.Find(Name)) + { + return *pIdx; + } + return INDEX_NONE; +} + +FName UNamedOptionWidgetBase::GetNameForIndex(int Index) +{ + if (auto pName = NameIndex.FindKey(Index)) + { + return *pName; + } + return NAME_None; +} + +void UNamedOptionWidgetBase::SetSelectedIndex(int NewIndex) +{ + // Update which name this is + // Do this first so that the event called in the superclass has this data as well + SelectedName = GetNameForIndex(NewIndex); + + Super::SetSelectedIndex(NewIndex); +} + +void UNamedOptionWidgetBase::SetSelectedName(FName Name) +{ + SetSelectedIndex(GetIndexForName(Name)); +} diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/OptionWidgetBase.cpp b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/OptionWidgetBase.cpp new file mode 100644 index 00000000..afe5fa67 --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/OptionWidgetBase.cpp @@ -0,0 +1,233 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +#include "StevesUI/OptionWidgetBase.h" + +#include "StevesUI.h" +#include "StevesGameSubsystem.h" +#include "StevesUEHelpers.h" +#include "Components/Button.h" +#include "Components/Image.h" +#include "Components/TextBlock.h" + +void UOptionWidgetBase::NativeConstruct() +{ + Super::NativeConstruct(); + + auto GS = GetStevesGameSubsystem(GetWorld()); + if (GS) + { + GS->OnInputModeChanged.AddDynamic(this, &UOptionWidgetBase::InputModeChanged); + UpdateFromInputMode(GS->GetLastInputModeUsed()); + } + else + UE_LOG(LogStevesUI, Error, TEXT("StevesGameSubsystem is missing!")); + + // Check we can bind everything we need, bind click + if (!MouseVersion) + UE_LOG(LogStevesUI, Error, TEXT("%s should have a MouseVersion instance."), *this->GetClass()->GetName()); + if (!GamepadVersion) + UE_LOG(LogStevesUI, Error, TEXT("%s should have a GamepadVersion instance."), *this->GetClass()->GetName()); + if (MouseUpButton) + MouseUpButton->OnClicked.AddDynamic(this, &UOptionWidgetBase::MouseUpClicked); + else + UE_LOG(LogStevesUI, Error, TEXT("%s should have a MouseUpButton instance."), *this->GetClass()->GetName()); + if (MouseDownButton) + MouseDownButton->OnClicked.AddDynamic(this, &UOptionWidgetBase::MouseDownClicked); + else + UE_LOG(LogStevesUI, Error, TEXT("%s should have a MouseDownButton instance."), *this->GetClass()->GetName()); + if (!MouseUpImage) + UE_LOG(LogStevesUI, Error, TEXT("%s should have a MouseUpImage instance."), *this->GetClass()->GetName()); + if (!MouseDownImage) + UE_LOG(LogStevesUI, Error, TEXT("%s should have a MouseDownImage instance."), *this->GetClass()->GetName()); + if (!GamepadUpImage) + UE_LOG(LogStevesUI, Error, TEXT("%s should have a GamepadUpImage instance."), *this->GetClass()->GetName()); + if (!GamepadDownImage) + UE_LOG(LogStevesUI, Error, TEXT("%s should have a GamepadDownImage instance."), *this->GetClass()->GetName()); + + + SynchronizeProperties(); + + // To support option set up in designer + SetSelectedIndex(SelectedIndex); +} + + +void UOptionWidgetBase::NativeDestruct() +{ + auto GS = GetStevesGameSubsystem(GetWorld()); + if (GS) + { + GS->OnInputModeChanged.RemoveAll(this); + } + + if (MouseUpButton) + MouseUpButton->OnClicked.RemoveAll(this); + if (MouseDownButton) + MouseDownButton->OnClicked.RemoveAll(this); + +} + +void UOptionWidgetBase::ChangeOption(int Delta) +{ + SetSelectedIndex(FMath::Clamp(SelectedIndex + Delta, 0, Options.Num() - 1)); +} + + +void UOptionWidgetBase::MouseUpClicked() +{ + ChangeOption(1); +} + +void UOptionWidgetBase::MouseDownClicked() +{ + ChangeOption(-1); +} + +void UOptionWidgetBase::ClearOptions() +{ + Options.Empty(); + SetSelectedIndex(-1); +} + +void UOptionWidgetBase::UpdateFromInputMode(EInputMode Mode) +{ + switch (Mode) + { + case EInputMode::Mouse: + SetMouseMode(); + break; + case EInputMode::Keyboard: + case EInputMode::Gamepad: + SetButtonMode(); + break; + case EInputMode::Unknown: + default: + break; + } +} + +void UOptionWidgetBase::InputModeChanged(int PlayerIndex, EInputMode NewMode) +{ + UpdateFromInputMode(NewMode); +} + + +void UOptionWidgetBase::SetMouseMode() +{ + if (!MouseVersion) + return; + + bool bHadFocus = false; + if (GamepadVersion) + { + bHadFocus = GamepadVersion->HasKeyboardFocus(); + GamepadVersion->SetVisibility(ESlateVisibility::Hidden); + } + + MouseVersion->SetVisibility(ESlateVisibility::Visible); + + if (bHadFocus) + SetFocusProperly(); + +} + +void UOptionWidgetBase::SetButtonMode() +{ + if (!GamepadVersion) + return; + const bool bHadFocus = (MouseUpButton && MouseUpButton->HasKeyboardFocus()) || (MouseDownButton && MouseDownButton->HasKeyboardFocus()); + + if (MouseVersion) + MouseVersion->SetVisibility(ESlateVisibility::Hidden); + + GamepadVersion->SetVisibility(ESlateVisibility::Visible); + + if (bHadFocus) + SetFocusProperly(); + +} + +void UOptionWidgetBase::SetFocusProperly_Implementation() +{ + if (GamepadVersion && GamepadVersion->IsVisible()) + GamepadVersion->SetFocus(); + else if (MouseUpButton && MouseDownButton) + MouseUpButton->GetVisibility() == ESlateVisibility::Visible ? MouseUpButton->SetFocus() : MouseDownButton->SetFocus(); +} + +void UOptionWidgetBase::SetSelectedIndex(int NewIndex) +{ + const bool bRaiseEvent = SelectedIndex != NewIndex; + + SelectedIndex = NewIndex; + + const FText NewText = GetSelectedOption(); + if (MouseText) + MouseText->SetText(NewText); + if (GamepadText) + GamepadText->SetText(NewText); + + UpdateUpDownButtons(); + + if (bRaiseEvent) + { + // BP opportunity + PostSelectedOptionChanged(); + OnSelectedOptionChanged.Broadcast(this, SelectedIndex); + } + +} + +void UOptionWidgetBase::UpdateUpDownButtons() +{ + const bool CanDecrease = SelectedIndex > 0; + const bool CanIncrease = SelectedIndex < Options.Num() - 1; + if (MouseDownButton) + MouseDownButton->SetVisibility(CanDecrease ? ESlateVisibility::Visible : ESlateVisibility::Hidden); + if (MouseUpButton) + MouseUpButton->SetVisibility(CanIncrease ? ESlateVisibility::Visible : ESlateVisibility::Hidden); + if (GamepadDownImage) + GamepadDownImage->SetVisibility(CanDecrease ? ESlateVisibility::Visible : ESlateVisibility::Hidden); + if (GamepadUpImage) + GamepadUpImage->SetVisibility(CanIncrease ? ESlateVisibility::Visible : ESlateVisibility::Hidden); + +} + + +int UOptionWidgetBase::AddOption(FText Option) +{ + const int Ret = Options.Add(Option); + if (GetSelectedIndex() == -1) + { + SetSelectedIndex(0); + } + else + { + UpdateUpDownButtons(); + } + return Ret; +} + +void UOptionWidgetBase::SetOptions(const TArray& InOptions, int NewSelectedIndex) +{ + Options = InOptions; + SetSelectedIndex(NewSelectedIndex); +} + +FText UOptionWidgetBase::GetSelectedOption() const +{ + if (Options.IsValidIndex(SelectedIndex)) + return Options[SelectedIndex]; + + return FText(); +} + +EInputMode UOptionWidgetBase::GetCurrentInputMode() const +{ + auto GS = GetStevesGameSubsystem(GetWorld()); + if (GS) + return GS->GetLastInputModeUsed(); + + return EInputMode::Unknown; +} + diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/RichTextBlockInputImageDecorator.cpp b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/RichTextBlockInputImageDecorator.cpp new file mode 100644 index 00000000..a32d0395 --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/RichTextBlockInputImageDecorator.cpp @@ -0,0 +1,338 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +#include "StevesUI/RichTextBlockInputImageDecorator.h" + + +#include "StevesHelperCommon.h" +#include "StevesUEHelpers.h" +#include "Fonts/FontMeasure.h" +#include "Kismet/GameplayStatics.h" +#include "Misc/DefaultValueHelper.h" +#include "Widgets/Layout/SScaleBox.h" +#include "Widgets/Layout/SBox.h" +#include "Widgets/Images/SImage.h" +#include "Framework/Application/SlateApplication.h" + + +// Slate SNew only supports 5 custom arguments so we need to batch things up +struct FRichTextInputImageParams +{ + /// What type of an input binding this image should look up + EInputBindingType BindingType; + /// If BindingType is Action/Axis, the name of it + FName ActionOrAxisName; + /// If BindingType is Key, the key + FKey Key; + /// If binding type is EnhancedInputAction, a reference to an enhanced input action + TSoftObjectPtr InputAction; + /// Player index, if binding type is action or axis + int PlayerIndex; + /// Where there are multiple mappings, which to prefer + EInputImageDevicePreference DevicePreference = EInputImageDevicePreference::Auto; + + /// Initial Sprite to use + UPaperSprite* InitialSprite; + /// Parent decorator, for looking up things later + URichTextBlockInputImageDecorator* Decorator; +}; +// Basically the same as SRichInlineImage but I can't re-use that since private +class SRichInlineInputImage : public SCompoundWidget +{ +protected: + /// What type of an input binding this image should look up + EInputBindingType BindingType = EInputBindingType::Key; + /// If BindingType is Action/Axis, the name of it + FName ActionOrAxisName; + /// If BindingType is Key, the key + FKey Key; + /// If binding type is EnhancedInputAction, a reference to an enhanced input action + TSoftObjectPtr InputAction; + /// Player index, if binding type is action or axis + int PlayerIndex = 0; + /// Where there are multiple mappings, which to prefer + EInputImageDevicePreference DevicePreference = EInputImageDevicePreference::Auto; + /// Parent decorator, for looking up things later + URichTextBlockInputImageDecorator* Decorator = nullptr; + + FSlateBrush Brush; + float TimeUntilNextSpriteCheck = 0; + uint16 MaxCharHeight = 0; + TOptional RequestedWidth; + TOptional RequestedHeight; + SBox* ContainerBox = nullptr; + +public: + SLATE_BEGIN_ARGS(SRichInlineInputImage) + {} + SLATE_END_ARGS() + +public: + + void Construct(const FArguments& InArgs, FRichTextInputImageParams InParams, + const FTextBlockStyle& TextStyle, TOptional Width, TOptional Height, EStretch::Type Stretch) + { + BindingType = InParams.BindingType; + ActionOrAxisName = InParams.ActionOrAxisName; + DevicePreference = InParams.DevicePreference; + Key = InParams.Key; + InputAction = InParams.InputAction; + PlayerIndex = InParams.PlayerIndex; + Decorator = InParams.Decorator; + RequestedWidth = Width; + RequestedHeight = Height; + + // Sadly, we cannot hook into the events needed to update based on input changes here + // All attempts to use GetStevesGameSubsystem() fail because the world pointer + // doesn't work, I think perhaps because this Slate Construct call is in another thread which pre-dates it. + // We will need to do the work to update the brush from the main thread later + + // We can use static methods though + if (IsValid(InParams.InitialSprite)) + UStevesGameSubsystem::SetBrushFromAtlas(&Brush, InParams.InitialSprite, true); + TimeUntilNextSpriteCheck = 0.25f; + + const TSharedRef FontMeasure = FSlateApplication::Get().GetRenderer()->GetFontMeasureService(); + MaxCharHeight = FontMeasure->GetMaxCharacterHeight(TextStyle.Font, 1.0f); + float IconHeight = FMath::Min(static_cast(MaxCharHeight), Brush.ImageSize.Y); + if (Height.IsSet()) + { + IconHeight = Height.GetValue(); + } + + float IconWidth = Brush.ImageSize.X * (IconHeight / Brush.ImageSize.Y) ; + if (Width.IsSet()) + { + IconWidth = Width.GetValue(); + } + + ChildSlot + [ + SNew(SBox) + .HeightOverride(IconHeight) + .WidthOverride(IconWidth) + [ + SNew(SScaleBox) + .Stretch(Stretch) + .StretchDirection(EStretchDirection::DownOnly) + .VAlign(VAlign_Center) + [ + SNew(SImage) + .Image(&Brush) + ] + ] + ]; + } + + + virtual void Tick(const FGeometry& AllottedGeometry, const double InCurrentTime, const float InDeltaTime) override + { + SCompoundWidget::Tick(AllottedGeometry, InCurrentTime, InDeltaTime); + + // I would prefer to hook into the events here but there is NO safe teardown in these RichText decorators + // with which to unsub from events, so we're down to brute forcing this in Tick() le sigh + // At least limit the frequency and only change as needed + TimeUntilNextSpriteCheck -= InDeltaTime; + if (TimeUntilNextSpriteCheck <= 0) + { + auto GS = GetStevesGameSubsystem(Decorator->GetWorld()); + if (GS) + { + // Can only support default theme, no way to edit theme in decorator config + UPaperSprite* Sprite = nullptr; + if (BindingType == EInputBindingType::EnhancedInputAction && !InputAction.IsNull()) + { + if (auto IA = InputAction.LoadSynchronous()) + { + auto PC = UGameplayStatics::GetPlayerController(Decorator->GetWorld(), PlayerIndex); + Sprite = GS->GetInputImageSpriteFromEnhancedInputAction(IA, DevicePreference, PlayerIndex, PC); + } + } + else + { + Sprite = GS->GetInputImageSprite(BindingType, ActionOrAxisName, Key, DevicePreference, PlayerIndex); + } + if (Sprite && Brush.GetResourceObject() != Sprite) + { + UStevesGameSubsystem::SetBrushFromAtlas(&Brush, Sprite, true); + + // Deal with aspect ratio changes + TSharedPtr Widget = ChildSlot.GetWidget(); + SBox* Box = static_cast(Widget.Get()); + float IconHeight = FMath::Min(static_cast(MaxCharHeight), Brush.ImageSize.Y); + if (RequestedHeight.IsSet()) + { + IconHeight = RequestedHeight.GetValue(); + } + + float IconWidth = Brush.ImageSize.X * (IconHeight / Brush.ImageSize.Y) ; + if (RequestedWidth.IsSet()) + { + IconWidth = RequestedWidth.GetValue(); + } + + Box->SetWidthOverride(IconWidth); + Box->SetHeightOverride(IconHeight); + + } + + } + TimeUntilNextSpriteCheck = 0.5f; + } + } +}; + +// Again, wish I could just subclass FRichInlineImage here, le sigh +class FRichInlineInputImage : public FRichTextDecorator +{ +public: + FRichInlineInputImage(URichTextBlock* InOwner, URichTextBlockInputImageDecorator* InDecorator) + : FRichTextDecorator(InOwner) + , Decorator(InDecorator) + { + } + + virtual bool Supports(const FTextRunParseResults& RunParseResult, const FString& Text) const override + { + if (RunParseResult.Name == TEXT("input")) + { + return RunParseResult.MetaData.Contains(TEXT("key")) || + RunParseResult.MetaData.Contains(TEXT("action")) || + RunParseResult.MetaData.Contains(TEXT("axis")) || + RunParseResult.MetaData.Contains(TEXT("eaction")); + } + + return false; + } + +protected: + + virtual TSharedPtr CreateDecoratorWidget(const FTextRunInfo& RunInfo, const FTextBlockStyle& TextStyle) const override + { + FRichTextInputImageParams Params; + Params.PlayerIndex = 0; + Params.BindingType = EInputBindingType::Key; + Params.Key = EKeys::AnyKey; + Params.Decorator = Decorator; + + auto GS = GetStevesGameSubsystem(Decorator->GetWorld()); + + if (const FString* PlayerStr = RunInfo.MetaData.Find(TEXT("player"))) + { + int PTemp; + Params.PlayerIndex = FDefaultValueHelper::ParseInt(*PlayerStr, PTemp) ? PTemp : 0; + } + + if (const FString* KeyStr = RunInfo.MetaData.Find(TEXT("key"))) + { + Params.BindingType = EInputBindingType::Key; + Params.Key = FKey(**KeyStr); + } + else if (const FString* ActionStr = RunInfo.MetaData.Find(TEXT("action"))) + { + Params.BindingType = EInputBindingType::Action; + Params.ActionOrAxisName = **ActionStr; + } + else if (const FString* AxisStr = RunInfo.MetaData.Find(TEXT("axis"))) + { + Params.BindingType = EInputBindingType::Axis; + Params.ActionOrAxisName = **AxisStr; + } + else if (const FString* EInputStr = RunInfo.MetaData.Find(TEXT("eaction"))) + { + Params.BindingType = EInputBindingType::EnhancedInputAction; + // Try to find the input action + if (GS) + { + Params.InputAction = GS->FindEnhancedInputAction(*EInputStr); + } + } + + if (const FString* PreferStr = RunInfo.MetaData.Find(TEXT("prefer"))) + { + if (PreferStr->Equals("auto", ESearchCase::IgnoreCase)) + { + Params.DevicePreference = EInputImageDevicePreference::Auto; + } + else if (PreferStr->Equals("gkm", ESearchCase::IgnoreCase)) + { + Params.DevicePreference = EInputImageDevicePreference::Gamepad_Keyboard_Mouse; + } + else if (PreferStr->Equals("gmk", ESearchCase::IgnoreCase)) + { + Params.DevicePreference = EInputImageDevicePreference::Gamepad_Mouse_Keyboard; + } + else if (PreferStr->Equals("gmkbutton", ESearchCase::IgnoreCase)) + { + Params.DevicePreference = EInputImageDevicePreference::Gamepad_Keyboard_Mouse_Button; + } + else if (PreferStr->Equals("gmkaxis", ESearchCase::IgnoreCase)) + { + Params.DevicePreference = EInputImageDevicePreference::Gamepad_Keyboard_Mouse_Axis; + } + } + + // Look up the initial sprite here + // The Slate widget can't do it in Construct because World pointer doesn't work (thread issues?) + // Also annoying: can't keep Brush on this class because this method is const. UGH + if (GS) + { + if (Params.BindingType == EInputBindingType::EnhancedInputAction && !Params.InputAction.IsNull()) + { + if (auto IA = Params.InputAction.LoadSynchronous()) + { + auto PC = UGameplayStatics::GetPlayerController(Decorator->GetWorld(), Params.PlayerIndex); + Params.InitialSprite = GS->GetInputImageSpriteFromEnhancedInputAction(IA, Params.DevicePreference, Params.PlayerIndex, PC); + } + } + else + { + // Can only support default theme, no way to edit theme in decorator config + Params.InitialSprite = GS->GetInputImageSprite(Params.BindingType, Params.ActionOrAxisName, Params.Key, Params.DevicePreference, Params.PlayerIndex); + } + } + else + { + // Might be false because this gets executed in the editor too + // TODO use a placeholder? + Params.InitialSprite = nullptr; + } + + + // Support the same width/height/stretch overrides as standard rich text images + TOptional Width; + if (const FString* WidthString = RunInfo.MetaData.Find(TEXT("width"))) + { + int32 WidthTemp; + Width = FDefaultValueHelper::ParseInt(*WidthString, WidthTemp) ? WidthTemp : TOptional(); + } + + TOptional Height; + if (const FString* HeightString = RunInfo.MetaData.Find(TEXT("height"))) + { + int32 HeightTemp; + Height = FDefaultValueHelper::ParseInt(*HeightString, HeightTemp) ? HeightTemp : TOptional(); + } + + EStretch::Type Stretch = EStretch::ScaleToFit; + if (const FString* SstretchString = RunInfo.MetaData.Find(TEXT("stretch"))) + { + static const UEnum* StretchEnum = StaticEnum(); + int64 StretchValue = StretchEnum->GetValueByNameString(*SstretchString); + if (StretchValue != INDEX_NONE) + { + Stretch = static_cast(StretchValue); + } + } + + // SNew only supports 5 custom arguments! Thats why we batch up in struct + return SNew(SRichInlineInputImage, Params, TextStyle, Width, Height, Stretch); + } + +private: + URichTextBlockInputImageDecorator* Decorator; +}; + +TSharedPtr URichTextBlockInputImageDecorator::CreateDecorator(URichTextBlock* InOwner) +{ + return MakeShareable(new FRichInlineInputImage(InOwner, this)); +} diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/SFocusableButton.cpp b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/SFocusableButton.cpp new file mode 100644 index 00000000..b2d64a33 --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/SFocusableButton.cpp @@ -0,0 +1,77 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +#include "StevesUI/SFocusableButton.h" + +void SFocusableButton::Construct(const FArguments& InArgs) +{ + // Call SButton construct and pass through + SButton::Construct(SButton::FArguments() + .ButtonStyle(InArgs._ButtonStyle) + .ClickMethod(InArgs._ClickMethod) + .ContentPadding(InArgs._ContentPadding) + .ContentScale(InArgs._ContentScale) + .FlowDirectionPreference(InArgs._FlowDirectionPreference) + .ForegroundColor(InArgs._ForegroundColor) + .HAlign(InArgs._HAlign) + .VAlign(InArgs._VAlign) + .IsFocusable(InArgs._IsFocusable) + .OnClicked(InArgs._OnClicked) + .OnHovered(InArgs._OnHovered) + .OnPressed(InArgs._OnPressed) + .OnReleased(InArgs._OnReleased) + .OnUnhovered(InArgs._OnUnhovered) + .PressMethod(InArgs._PressMethod) + .TouchMethod(InArgs._TouchMethod) + .DesiredSizeScale(InArgs._DesiredSizeScale) + .HoveredSoundOverride(InArgs._HoveredSoundOverride) + .PressedSoundOverride(InArgs._PressedSoundOverride) + .ButtonColorAndOpacity(InArgs._ButtonColorAndOpacity) + .TextFlowDirection(InArgs._TextFlowDirection) + .TextShapingMethod(InArgs._TextShapingMethod) + .Clipping(InArgs._Clipping) + .Cursor(InArgs._Cursor) + .Tag(InArgs._Tag) + .Visibility(InArgs._Visibility) + .AccessibleParams(InArgs._AccessibleParams) + .AccessibleText(InArgs._AccessibleText) + .ForceVolatile(InArgs._ForceVolatile) + .IsEnabled(InArgs._IsEnabled) + .RenderOpacity(InArgs._RenderOpacity) + .RenderTransform(InArgs._RenderTransform) + .RenderTransformPivot(InArgs._RenderTransformPivot) + .Text(InArgs._Text) + .TextStyle(InArgs._TextStyle) + .ToolTip(InArgs._ToolTip) + .ToolTipText(InArgs._ToolTipText) + ); + + OnFocusReceivedDelegate = InArgs._OnFocusReceived; + OnFocusLostDelegate = InArgs._OnFocusLost; +} + +FReply SFocusableButton::OnFocusReceived(const FGeometry& MyGeometry, const FFocusEvent& InFocusEvent) +{ + auto Ret = SButton::OnFocusReceived(MyGeometry, InFocusEvent); + + OnFocusReceivedDelegate.ExecuteIfBound(); + + return Ret; +} + +void SFocusableButton::OnFocusLost(const FFocusEvent& InFocusEvent) +{ + SButton::OnFocusLost(InFocusEvent); + + OnFocusLostDelegate.ExecuteIfBound(); +} + +void SFocusableButton::SetOnFocusReceived(FSimpleDelegate InOnFocusReceived) +{ + OnFocusReceivedDelegate = InOnFocusReceived; +} + +void SFocusableButton::SetOnFocusLost(FSimpleDelegate InOnFocusLost) +{ + OnFocusLostDelegate = InOnFocusLost; +} + diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/SFocusableButton.h b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/SFocusableButton.h new file mode 100644 index 00000000..e09a0953 --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/SFocusableButton.h @@ -0,0 +1,118 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +#pragma once + +#include "CoreMinimal.h" +#include "Widgets/Input/SButton.h" + +class SFocusableButton : public SButton +{ + // Seems in slate we have to duplicate all the args from superclass + SLATE_BEGIN_ARGS(SFocusableButton) + : _Content() + , _ButtonStyle( &FCoreStyle::Get().GetWidgetStyle< FButtonStyle >( "Button" ) ) + , _TextStyle( &FCoreStyle::Get().GetWidgetStyle< FTextBlockStyle >("NormalText") ) + , _HAlign( HAlign_Fill ) + , _VAlign( VAlign_Fill ) + , _ContentPadding(FMargin(4.0, 2.0)) + , _Text() + , _ClickMethod( EButtonClickMethod::DownAndUp ) + , _TouchMethod( EButtonTouchMethod::DownAndUp ) + , _PressMethod( EButtonPressMethod::DownAndUp ) + , _DesiredSizeScale( FVector2D(1,1) ) + , _ContentScale( FVector2D(1,1) ) + , _ButtonColorAndOpacity(FLinearColor::White) + , _ForegroundColor( FCoreStyle::Get().GetSlateColor( "InvertedForeground" ) ) + , _IsFocusable( true ) + { + } + + /** Slot for this button's content (optional) */ + SLATE_DEFAULT_SLOT( FArguments, Content ) + + /** The visual style of the button */ + SLATE_STYLE_ARGUMENT( FButtonStyle, ButtonStyle ) + + /** The text style of the button */ + SLATE_STYLE_ARGUMENT( FTextBlockStyle, TextStyle ) + + /** Horizontal alignment */ + SLATE_ARGUMENT( EHorizontalAlignment, HAlign ) + + /** Vertical alignment */ + SLATE_ARGUMENT( EVerticalAlignment, VAlign ) + + /** Spacing between button's border and the content. */ + SLATE_ATTRIBUTE( FMargin, ContentPadding ) + + /** The text to display in this button, if no custom content is specified */ + SLATE_ATTRIBUTE( FText, Text ) + + /** Called when the button is clicked */ + SLATE_EVENT( FOnClicked, OnClicked ) + + /** Called when the button is pressed */ + SLATE_EVENT( FSimpleDelegate, OnPressed ) + + /** Called when the button is released */ + SLATE_EVENT( FSimpleDelegate, OnReleased ) + + SLATE_EVENT( FSimpleDelegate, OnHovered ) + + SLATE_EVENT( FSimpleDelegate, OnUnhovered ) + + /** Sets the rules to use for determining whether the button was clicked. This is an advanced setting and generally should be left as the default. */ + SLATE_ARGUMENT( EButtonClickMethod::Type, ClickMethod ) + + /** How should the button be clicked with touch events? */ + SLATE_ARGUMENT( EButtonTouchMethod::Type, TouchMethod ) + + /** How should the button be clicked with keyboard/controller button events? */ + SLATE_ARGUMENT( EButtonPressMethod::Type, PressMethod ) + + SLATE_ATTRIBUTE( FVector2D, DesiredSizeScale ) + + SLATE_ATTRIBUTE( FVector2D, ContentScale ) + + SLATE_ATTRIBUTE( FSlateColor, ButtonColorAndOpacity ) + + SLATE_ATTRIBUTE( FSlateColor, ForegroundColor ) + + /** Sometimes a button should only be mouse-clickable and never keyboard focusable. */ + SLATE_ARGUMENT( bool, IsFocusable ) + + /** The sound to play when the button is pressed */ + SLATE_ARGUMENT( TOptional, PressedSoundOverride ) + + /** The sound to play when the button is hovered */ + SLATE_ARGUMENT( TOptional, HoveredSoundOverride ) + + /** Which text shaping method should we use? (unset to use the default returned by GetDefaultTextShapingMethod) */ + SLATE_ARGUMENT( TOptional, TextShapingMethod ) + + /** Which text flow direction should we use? (unset to use the default returned by GetDefaultTextFlowDirection) */ + SLATE_ARGUMENT( TOptional, TextFlowDirection ) + + // This is the bit we're adding + SLATE_EVENT( FSimpleDelegate, OnFocusReceived ) + SLATE_EVENT( FSimpleDelegate, OnFocusLost ) + + SLATE_END_ARGS() + +public: + + void Construct( const FArguments& InArgs ); + + virtual FReply OnFocusReceived(const FGeometry& MyGeometry, const FFocusEvent& InFocusEvent) override; + virtual void OnFocusLost(const FFocusEvent& InFocusEvent) override; + + void SetOnFocusReceived(FSimpleDelegate InOnFocusReceived); + void SetOnFocusLost(FSimpleDelegate InOnFocusLost); + +protected: + FSimpleDelegate OnFocusReceivedDelegate; + FSimpleDelegate OnFocusLostDelegate; + +}; + + diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/SFocusableCheckBox.cpp b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/SFocusableCheckBox.cpp new file mode 100644 index 00000000..435cb9ee --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/SFocusableCheckBox.cpp @@ -0,0 +1,115 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +#include "SFocusableCheckBox.h" +#include "Framework/Application/SlateApplication.h" + +void SFocusableCheckBox::Construct(const FArguments& InArgs) +{ + // Call superclass, have to re-construct the args because they're not compatible + SCheckBox::Construct(SCheckBox::FArguments() + .Padding(InArgs._Padding) + .Style(InArgs._Style) + .Type(InArgs._Type) + .CheckedImage(InArgs._CheckedImage) + .ClickMethod(InArgs._ClickMethod) + .ForegroundColor(InArgs._ForegroundColor) + .HAlign(InArgs._HAlign) + .IsChecked(InArgs._IsChecked) + .IsFocusable(InArgs._IsFocusable) + .PressMethod(InArgs._PressMethod) + .TouchMethod(InArgs._TouchMethod) + .UncheckedImage(InArgs._UncheckedImage) + .UndeterminedImage(InArgs._UndeterminedImage) + .CheckedImage(InArgs._CheckedImage) + .UncheckedHoveredImage(InArgs._UncheckedHoveredImage) + .UndeterminedHoveredImage(InArgs._UndeterminedHoveredImage) + .CheckedHoveredImage(InArgs._CheckedHoveredImage) + .UncheckedPressedImage(InArgs._UncheckedPressedImage) + .UndeterminedPressedImage(InArgs._UndeterminedPressedImage) + .CheckedPressedImage(InArgs._CheckedPressedImage) + .CheckedSoundOverride(InArgs._CheckedSoundOverride) + .UncheckedSoundOverride(InArgs._UncheckedSoundOverride) + .HoveredSoundOverride(InArgs._HoveredSoundOverride) + .BorderBackgroundColor(InArgs._BorderBackgroundColor) + .OnCheckStateChanged(InArgs._OnCheckStateChanged) + .IsEnabled(InArgs._IsEnabled) + .Clipping(InArgs._Clipping) + .Cursor(InArgs._Cursor) + .Tag(InArgs._Tag) + .Visibility(InArgs._Visibility) + .AccessibleParams(InArgs._AccessibleParams) + .AccessibleText(InArgs._AccessibleText) + .ForceVolatile(InArgs._ForceVolatile) + .IsEnabled(InArgs._IsEnabled) + .RenderOpacity(InArgs._RenderOpacity) + .RenderTransform(InArgs._RenderTransform) + .RenderTransformPivot(InArgs._RenderTransformPivot) + .ToolTip(InArgs._ToolTip) + .ToolTipText(InArgs._ToolTipText) + ); + + OnHoveredDelegate = InArgs._OnHovered; + OnUnhoveredDelegate = InArgs._OnUnhovered; + OnFocusReceivedDelegate = InArgs._OnFocusReceived; + OnFocusLostDelegate = InArgs._OnFocusLost; + + +} + + +FReply SFocusableCheckBox::OnKeyDown(const FGeometry& MyGeometry, const FKeyEvent& InKeyEvent) +{ + // Default 4.26.1 behaviour is to return Unhandled for ANY keys apart from Accept, which breaks keyboard nav + // This is a bug and it's been fixed in the source version but not released yet, so work around it + if (FSlateApplication::Get().GetNavigationActionFromKey(InKeyEvent) == EUINavigationAction::Accept) + { + // for accept, let the immediate superclass do it + return SCheckBox::OnKeyDown(MyGeometry, InKeyEvent); + } + else + { + // for any other key, the super-super needs to handle it to support navigation + return SCompoundWidget::OnKeyDown(MyGeometry, InKeyEvent); + } +} + +void SFocusableCheckBox::OnMouseEnter(const FGeometry& MyGeometry, const FPointerEvent& MouseEvent) +{ + SCheckBox::OnMouseEnter(MyGeometry, MouseEvent); + + // SCheckbox doesn't have hovered / unhovered events so we need to add them + OnHoveredDelegate.ExecuteIfBound(); +} + +void SFocusableCheckBox::OnMouseLeave(const FPointerEvent& MouseEvent) +{ + SCheckBox::OnMouseLeave(MouseEvent); + + OnUnhoveredDelegate.ExecuteIfBound(); +} + +FReply SFocusableCheckBox::OnFocusReceived(const FGeometry& MyGeometry, const FFocusEvent& InFocusEvent) +{ + auto Ret = SCheckBox::OnFocusReceived(MyGeometry, InFocusEvent); + + OnFocusReceivedDelegate.ExecuteIfBound(); + + return Ret; +} + +void SFocusableCheckBox::OnFocusLost(const FFocusEvent& InFocusEvent) +{ + SCheckBox::OnFocusLost(InFocusEvent); + + OnFocusLostDelegate.ExecuteIfBound(); +} + +void SFocusableCheckBox::SetOnFocusReceived(FSimpleDelegate InOnFocusReceived) +{ + OnFocusReceivedDelegate = InOnFocusReceived; +} + +void SFocusableCheckBox::SetOnFocusLost(FSimpleDelegate InOnFocusLost) +{ + OnFocusLostDelegate = InOnFocusLost; +} diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/SFocusableCheckBox.h b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/SFocusableCheckBox.h new file mode 100644 index 00000000..9097dfe8 --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/SFocusableCheckBox.h @@ -0,0 +1,149 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +#pragma once + +#include "CoreMinimal.h" +#include "Widgets/Input/SCheckBox.h" + + +class SFocusableCheckBox : public SCheckBox +{ + public: + + // Seems in slate we have to duplicate all the args from superclass + SLATE_BEGIN_ARGS( SFocusableCheckBox ) + : _Content() + , _Style( &FCoreStyle::Get().GetWidgetStyle< FCheckBoxStyle >("Checkbox") ) + , _Type() + , _OnCheckStateChanged() + , _IsChecked( ECheckBoxState::Unchecked ) + , _HAlign( HAlign_Fill ) + , _CheckBoxContentUsesAutoWidth(true) + , _Padding() + , _ClickMethod( EButtonClickMethod::DownAndUp ) + , _TouchMethod(EButtonTouchMethod::DownAndUp) + , _PressMethod(EButtonPressMethod::DownAndUp) + , _ForegroundColor(FSlateColor(FColor::White)) + , _BorderBackgroundColor (FSlateColor(FColor::White)) + , _IsFocusable( true ) + , _UncheckedImage( nullptr ) + , _UncheckedHoveredImage( nullptr ) + , _UncheckedPressedImage( nullptr ) + , _CheckedImage( nullptr ) + , _CheckedHoveredImage( nullptr ) + , _CheckedPressedImage( nullptr ) + , _UndeterminedImage( nullptr ) + , _UndeterminedHoveredImage( nullptr ) + , _UndeterminedPressedImage( nullptr ) + { + } + /** Content to be placed next to the check box, or for a toggle button, the content to be placed inside the button */ + SLATE_DEFAULT_SLOT( FArguments, Content ) + + /** The style structure for this checkbox' visual style */ + SLATE_STYLE_ARGUMENT( FCheckBoxStyle, Style ) + + /** Type of check box (set by the Style arg but the Style can be overridden with this) */ + SLATE_ARGUMENT( TOptional, Type ) + + /** Called when the checked state has changed */ + SLATE_EVENT( FOnCheckStateChanged, OnCheckStateChanged ) + + /** Whether the check box is currently in a checked state */ + SLATE_ATTRIBUTE( ECheckBoxState, IsChecked ) + + /** How the content of the toggle button should align within the given space*/ + SLATE_ARGUMENT( EHorizontalAlignment, HAlign ) + + /** Whether or not the content portion of the checkbox should layout using auto-width. When true the content will always be arranged at its desired size as opposed to resizing to the available space. */ + SLATE_ARGUMENT(bool, CheckBoxContentUsesAutoWidth) + + /** Spacing between the check box image and its content (set by the Style arg but the Style can be overridden with this) */ + SLATE_ATTRIBUTE( FMargin, Padding ) + + /** Sets the rules to use for determining whether the button was clicked. This is an advanced setting and generally should be left as the default. */ + SLATE_ARGUMENT( EButtonClickMethod::Type, ClickMethod ) + + /** How should the button be clicked with touch events? */ + SLATE_ARGUMENT(EButtonTouchMethod::Type, TouchMethod) + + /** How should the button be clicked with keyboard/controller button events? */ + SLATE_ARGUMENT(EButtonPressMethod::Type, PressMethod) + + /** Foreground color for the checkbox's content and parts (set by the Style arg but the Style can be overridden with this) */ + SLATE_ATTRIBUTE( FSlateColor, ForegroundColor ) + + /** The color of the background border (set by the Style arg but the Style can be overridden with this) */ + SLATE_ATTRIBUTE( FSlateColor, BorderBackgroundColor ) + + SLATE_ARGUMENT( bool, IsFocusable ) + + SLATE_EVENT( FOnGetContent, OnGetMenuContent ) + + /** The sound to play when the check box is checked */ + SLATE_ARGUMENT( TOptional, CheckedSoundOverride ) + + /** The sound to play when the check box is unchecked */ + SLATE_ARGUMENT( TOptional, UncheckedSoundOverride ) + + /** The sound to play when the check box is hovered */ + SLATE_ARGUMENT( TOptional, HoveredSoundOverride ) + + /** The unchecked image for the checkbox - overrides the style's */ + SLATE_ARGUMENT(const FSlateBrush*, UncheckedImage) + + /** The unchecked hovered image for the checkbox - overrides the style's */ + SLATE_ARGUMENT(const FSlateBrush*, UncheckedHoveredImage) + + /** The unchecked pressed image for the checkbox - overrides the style's */ + SLATE_ARGUMENT(const FSlateBrush*, UncheckedPressedImage) + + /** The checked image for the checkbox - overrides the style's */ + SLATE_ARGUMENT(const FSlateBrush*, CheckedImage) + + /** The checked hovered image for the checkbox - overrides the style's */ + SLATE_ARGUMENT(const FSlateBrush*, CheckedHoveredImage) + + /** The checked pressed image for the checkbox - overrides the style's */ + SLATE_ARGUMENT(const FSlateBrush*, CheckedPressedImage) + + /** The undetermined image for the checkbox - overrides the style's */ + SLATE_ARGUMENT(const FSlateBrush*, UndeterminedImage) + + /** The undetermined hovered image for the checkbox - overrides the style's */ + SLATE_ARGUMENT(const FSlateBrush*, UndeterminedHoveredImage) + + /** The undetermined pressed image for the checkbox - overrides the style's */ + SLATE_ARGUMENT(const FSlateBrush*, UndeterminedPressedImage) + + // This is the bit we're adding + // SCheckBox is missing hovered/unhovered too + SLATE_EVENT( FSimpleDelegate, OnHovered ) + SLATE_EVENT( FSimpleDelegate, OnUnhovered ) + SLATE_EVENT( FSimpleDelegate, OnFocusReceived ) + SLATE_EVENT( FSimpleDelegate, OnFocusLost ) + + SLATE_END_ARGS() + +public: + + void Construct( const FArguments& InArgs ); + + virtual FReply OnFocusReceived(const FGeometry& MyGeometry, const FFocusEvent& InFocusEvent) override; + virtual void OnFocusLost(const FFocusEvent& InFocusEvent) override; + + void SetOnFocusReceived(FSimpleDelegate InOnFocusReceived); + void SetOnFocusLost(FSimpleDelegate InOnFocusLost); + +protected: + FSimpleDelegate OnHoveredDelegate; + FSimpleDelegate OnUnhoveredDelegate; + FSimpleDelegate OnFocusReceivedDelegate; + FSimpleDelegate OnFocusLostDelegate; + + +public: + virtual FReply OnKeyDown(const FGeometry& MyGeometry, const FKeyEvent& InKeyEvent) override; + virtual void OnMouseEnter(const FGeometry& MyGeometry, const FPointerEvent& MouseEvent) override; + virtual void OnMouseLeave(const FPointerEvent& MouseEvent) override; +}; \ No newline at end of file diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/SFocusableSlider.cpp b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/SFocusableSlider.cpp new file mode 100644 index 00000000..9ff99223 --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/SFocusableSlider.cpp @@ -0,0 +1,81 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +#include "SFocusableSlider.h" + +void SFocusableSlider::Construct(const FArguments& InArgs) +{ + // Call superclass, have to re-construct the args because they're not compatible + SSlider::Construct(SSlider::FArguments() + .Locked(InArgs._Locked) + .Orientation(InArgs._Orientation) + .Style(InArgs._Style) + .Value(InArgs._Value) + .MaxValue(InArgs._MaxValue) + .MinValue(InArgs._MinValue) + .IsFocusable(InArgs._IsFocusable) + .IsEnabled(InArgs._IsEnabled) + .IndentHandle(InArgs._IndentHandle) + .MouseUsesStep(InArgs._MouseUsesStep) + .RequiresControllerLock(InArgs._RequiresControllerLock) + .Clipping(InArgs._Clipping) + .Cursor(InArgs._Cursor) + .Tag(InArgs._Tag) + .Visibility(InArgs._Visibility) + .AccessibleParams(InArgs._AccessibleParams) + .AccessibleText(InArgs._AccessibleText) + .ForceVolatile(InArgs._ForceVolatile) + .RenderOpacity(InArgs._RenderOpacity) + .RenderTransform(InArgs._RenderTransform) + .RenderTransformPivot(InArgs._RenderTransformPivot) + .ToolTip(InArgs._ToolTip) + .ToolTipText(InArgs._ToolTipText) + .OnValueChanged(InArgs._OnValueChanged) + ); + + OnHoveredDelegate = InArgs._OnHovered; + OnUnhoveredDelegate = InArgs._OnUnhovered; + OnFocusReceivedDelegate = InArgs._OnFocusReceived; + OnFocusLostDelegate = InArgs._OnFocusLost; +} + + +void SFocusableSlider::OnMouseEnter(const FGeometry& MyGeometry, const FPointerEvent& MouseEvent) +{ + SSlider::OnMouseEnter(MyGeometry, MouseEvent); + + // SSlider doesn't have hovered / unhovered events so we need to add them + OnHoveredDelegate.ExecuteIfBound(); +} + +void SFocusableSlider::OnMouseLeave(const FPointerEvent& MouseEvent) +{ + SSlider::OnMouseLeave(MouseEvent); + + OnUnhoveredDelegate.ExecuteIfBound(); +} + +FReply SFocusableSlider::OnFocusReceived(const FGeometry& MyGeometry, const FFocusEvent& InFocusEvent) +{ + auto Ret = SSlider::OnFocusReceived(MyGeometry, InFocusEvent); + + OnFocusReceivedDelegate.ExecuteIfBound(); + + return Ret; +} + +void SFocusableSlider::OnFocusLost(const FFocusEvent& InFocusEvent) +{ + SSlider::OnFocusLost(InFocusEvent); + + OnFocusLostDelegate.ExecuteIfBound(); +} + +void SFocusableSlider::SetOnFocusReceived(FSimpleDelegate InOnFocusReceived) +{ + OnFocusReceivedDelegate = InOnFocusReceived; +} + +void SFocusableSlider::SetOnFocusLost(FSimpleDelegate InOnFocusLost) +{ + OnFocusLostDelegate = InOnFocusLost; +} diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/SFocusableSlider.h b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/SFocusableSlider.h new file mode 100644 index 00000000..5a63ef52 --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/SFocusableSlider.h @@ -0,0 +1,115 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +#pragma once + +#include "CoreMinimal.h" +#include "Widgets/Input/SSlider.h" + + +class SFocusableSlider : public SSlider +{ + public: + + // Seems in slate we have to duplicate all the args from superclass + SLATE_BEGIN_ARGS(SFocusableSlider) + : _IndentHandle(true) + , _MouseUsesStep(false) + , _RequiresControllerLock(true) + , _Locked(false) + , _Orientation(EOrientation::Orient_Horizontal) + , _SliderBarColor(FLinearColor::White) + , _SliderHandleColor(FLinearColor::White) + , _Style(&FCoreStyle::Get().GetWidgetStyle("Slider")) + , _StepSize(0.01f) + , _Value(1.f) + , _MinValue(0.0f) + , _MaxValue(1.0f) + , _IsFocusable(true) + , _OnMouseCaptureBegin() + , _OnMouseCaptureEnd() + , _OnValueChanged() + { + } + + /** Whether the slidable area should be indented to fit the handle. */ + SLATE_ATTRIBUTE( bool, IndentHandle ) + + /** Sets new value if mouse position is greater/less than half the step size. */ + SLATE_ARGUMENT( bool, MouseUsesStep ) + + /** Sets whether we have to lock input to change the slider value. */ + SLATE_ARGUMENT( bool, RequiresControllerLock ) + + /** Whether the handle is interactive or fixed. */ + SLATE_ATTRIBUTE( bool, Locked ) + + /** The slider's orientation. */ + SLATE_ARGUMENT( EOrientation, Orientation) + + /** The color to draw the slider bar in. */ + SLATE_ATTRIBUTE( FSlateColor, SliderBarColor ) + + /** The color to draw the slider handle in. */ + SLATE_ATTRIBUTE( FSlateColor, SliderHandleColor ) + + /** The style used to draw the slider. */ + SLATE_STYLE_ARGUMENT( FSliderStyle, Style ) + + /** The input mode while using the controller. */ + SLATE_ATTRIBUTE(float, StepSize) + + /** A value that drives where the slider handle appears. Value is normalized between 0 and 1. */ + SLATE_ATTRIBUTE( float, Value ) + + /** The minimum value that can be specified by using the slider. */ + SLATE_ARGUMENT(float, MinValue) + /** The maximum value that can be specified by using the slider. */ + SLATE_ARGUMENT(float, MaxValue) + + /** Sometimes a slider should only be mouse-clickable and never keyboard focusable. */ + SLATE_ARGUMENT(bool, IsFocusable) + + /** Invoked when the mouse is pressed and a capture begins. */ + SLATE_EVENT(FSimpleDelegate, OnMouseCaptureBegin) + + /** Invoked when the mouse is released and a capture ends. */ + SLATE_EVENT(FSimpleDelegate, OnMouseCaptureEnd) + + /** Invoked when the Controller is pressed and capture begins. */ + SLATE_EVENT(FSimpleDelegate, OnControllerCaptureBegin) + + /** Invoked when the controller capture is released. */ + SLATE_EVENT(FSimpleDelegate, OnControllerCaptureEnd) + + /** Called when the value is changed by the slider. */ + SLATE_EVENT( FOnFloatValueChanged, OnValueChanged ) + + // This is the bit we're adding + // SCheckBox is missing hovered/unhovered too + SLATE_EVENT( FSimpleDelegate, OnHovered ) + SLATE_EVENT( FSimpleDelegate, OnUnhovered ) + SLATE_EVENT( FSimpleDelegate, OnFocusReceived ) + SLATE_EVENT( FSimpleDelegate, OnFocusLost ) + + SLATE_END_ARGS() +public: + + void Construct( const FArguments& InArgs ); + + virtual FReply OnFocusReceived(const FGeometry& MyGeometry, const FFocusEvent& InFocusEvent) override; + virtual void OnFocusLost(const FFocusEvent& InFocusEvent) override; + + void SetOnFocusReceived(FSimpleDelegate InOnFocusReceived); + void SetOnFocusLost(FSimpleDelegate InOnFocusLost); + +protected: + FSimpleDelegate OnHoveredDelegate; + FSimpleDelegate OnUnhoveredDelegate; + FSimpleDelegate OnFocusReceivedDelegate; + FSimpleDelegate OnFocusLostDelegate; + + +public: + virtual void OnMouseEnter(const FGeometry& MyGeometry, const FPointerEvent& MouseEvent) override; + virtual void OnMouseLeave(const FPointerEvent& MouseEvent) override; +}; \ No newline at end of file diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/SmoothChangingProgressBar.cpp b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/SmoothChangingProgressBar.cpp new file mode 100644 index 00000000..f161e81e --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/SmoothChangingProgressBar.cpp @@ -0,0 +1,64 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +#include "StevesUI/SmoothChangingProgressBar.h" + +void USmoothChangingProgressBar::SetPercentSmoothly(float InPercent) +{ + UnregisterTimer(); + + TargetPercent = InPercent; + const float Curr = GetPercent(); + if (!FMath::IsNearlyEqual(Curr, TargetPercent)) + { + if (FMath::IsNearlyZero(PercentChangeSpeed) || !MyProgressBar.IsValid()) + { + SetPercent(InPercent); + } + else + { + SmoothChangeHandle = MyProgressBar->RegisterActiveTimer( + PercentChangeFrequency, + FWidgetActiveTimerDelegate::CreateUObject(this, &USmoothChangingProgressBar::TickPercent)); + } + } +} + +void USmoothChangingProgressBar::StopSmoothPercentChange() +{ + UnregisterTimer(); +} + +void USmoothChangingProgressBar::BeginDestroy() +{ + Super::BeginDestroy(); + + UnregisterTimer(); +} + +void USmoothChangingProgressBar::UnregisterTimer() +{ + if (SmoothChangeHandle.IsValid() && MyProgressBar.IsValid()) + { + MyProgressBar->UnRegisterActiveTimer(SmoothChangeHandle.Pin().ToSharedRef()); + SmoothChangeHandle.Reset(); + } +} + +EActiveTimerReturnType USmoothChangingProgressBar::TickPercent(double CurrTime, float DeltaTime) +{ + const float CurrPercent = GetPercent(); + const float Direction = FMath::Sign(TargetPercent - CurrPercent); + const float Change = DeltaTime * Direction * PercentChangeSpeed; + + const float NewPercent = Direction > 0 + ? FMath::Min(CurrPercent + Change, TargetPercent) + : FMath::Max(CurrPercent + Change, TargetPercent); + SetPercent(NewPercent); + + // Stop this if reached target (will unregister itself) + if (FMath::IsNearlyEqual(TargetPercent, GetPercent())) + { + return EActiveTimerReturnType::Stop; + } + return EActiveTimerReturnType::Continue; +} diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/StevesSubtitleTextblock.cpp b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/StevesSubtitleTextblock.cpp new file mode 100644 index 00000000..08d1ee6b --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/StevesSubtitleTextblock.cpp @@ -0,0 +1,48 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license + + +#include "StevesUI/StevesSubtitleTextblock.h" + +#include "SubtitleManager.h" +#include "Kismet/GameplayStatics.h" + +TSharedRef UStevesSubtitleTextblock::RebuildWidget() +{ + auto Ret = Super::RebuildWidget(); + + if (!bSubscribed) + { + if (auto SM = FSubtitleManager::GetSubtitleManager()) + { + SM->OnSetSubtitleText().AddUObject(this, &UStevesSubtitleTextblock::SetSubtitleText); + bSubscribed = true; + } + } + + return Ret; + +} + +void UStevesSubtitleTextblock::BeginDestroy() +{ + Super::BeginDestroy(); + + if (bSubscribed) + { + if (auto SM = FSubtitleManager::GetSubtitleManager()) + { + SM->OnSetSubtitleText().RemoveAll(this); + bSubscribed = false; + } + } +} + +void UStevesSubtitleTextblock::SetSubtitleText(const FText& InText) +{ + // We get called even when subtitles are disabled + if (UGameplayStatics::AreSubtitlesEnabled() || InText.IsEmptyOrWhitespace()) + { + SetText(InText); + } +} diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/StevesUI.cpp b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/StevesUI.cpp new file mode 100644 index 00000000..1922a95e --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/StevesUI.cpp @@ -0,0 +1,117 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +#include "StevesUI.h" + + + +#include "StevesUI/FocusableUserWidget.h" +#include "Blueprint/WidgetTree.h" +#include "Components/PanelWidget.h" +#include "Components/Widget.h" + +DEFINE_LOG_CATEGORY(LogStevesUI); + +UWidget* FindWidgetFromSlate(SWidget* SW, UWidget* Parent) +{ + if (!Parent) + return nullptr; + + if (Parent->GetCachedWidget().Get() == SW) + return Parent; + + auto PW = Cast(Parent); + if (PW) + { + for (int i = 0; i < PW->GetChildrenCount(); ++i) + { + const auto Found = FindWidgetFromSlate(SW, PW->GetChildAt(i)); + if (Found) + return Found; + } + } + else + { + // User widgets aren't panels but can have their own tree + auto UW = Cast(Parent); + if (UW) + { + return FindWidgetFromSlate(SW, UW->WidgetTree->RootWidget); + } + + } + return nullptr; +} + +void SetWidgetFocusProperly(UWidget* Widget) +{ + auto FW = Cast(Widget); + if (FW) + FW->SetFocusProperly(); + else + Widget->SetFocus(); + +} + +const FKey* GetPreferedKeyMapping(const TArray& AllKeys, + EInputImageDevicePreference DevicePreference, + EInputMode LastInputDevice, + EInputMode LastButtonInputDevice, + EInputMode LastAxisInputDevice) +{ + // Same as GetPreferedActionOrAxisMapping, just with key directly + const FKey* MouseMapping = nullptr; + const FKey* KeyboardMapping = nullptr; + const FKey* GamepadMapping = nullptr; + for (const FKey& Key : AllKeys) + { + // notice how we take the LAST one in the list as the final version + // this is because UInputSettings::GetActionMappingByName *reverses* the mapping list from Project Settings + if (Key.IsGamepadKey()) + { + GamepadMapping = &Key; + } + else if (Key.IsMouseButton()) // registers true for mouse axes too + { + MouseMapping = &Key; + } + else + { + KeyboardMapping = &Key; + } + } + + const FKey* Preferred = nullptr; + if (GamepadMapping && LastInputDevice == EInputMode::Gamepad) + { + // Always prefer gamepad if used last + Preferred = GamepadMapping; + } + else + { + switch (DevicePreference) + { + // Auto should be pre-converted to another + case EInputImageDevicePreference::Auto: + UE_LOG(LogStevesUI, Error, TEXT("Device Preference should have been converted before this call")) + break; + case EInputImageDevicePreference::Gamepad_Keyboard_Mouse: + Preferred = KeyboardMapping ? KeyboardMapping : MouseMapping; + break; + case EInputImageDevicePreference::Gamepad_Mouse_Keyboard: + Preferred = MouseMapping ? MouseMapping : KeyboardMapping; + break; + + case EInputImageDevicePreference::Gamepad_Keyboard_Mouse_Button: + // Use the latest button press + Preferred = (MouseMapping && (LastButtonInputDevice == EInputMode::Mouse || !KeyboardMapping)) ? MouseMapping : KeyboardMapping; + break; + case EInputImageDevicePreference::Gamepad_Keyboard_Mouse_Axis: + // Use the latest button press + Preferred = (MouseMapping && (LastAxisInputDevice == EInputMode::Mouse || !KeyboardMapping)) ? MouseMapping : KeyboardMapping; + break; + default: + break; + } + } + return Preferred; +} diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/StevesUI.h b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/StevesUI.h new file mode 100644 index 00000000..f3a14e5f --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/StevesUI.h @@ -0,0 +1,101 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +#pragma once + +#include "CoreMinimal.h" +#include "InputCoreTypes.h" +#include "StevesHelperCommon.h" + +class UWidget; +class SWidget; + +DECLARE_LOG_CATEGORY_EXTERN(LogStevesUI, Warning, Warning) + + +/** + * @brief Tries to locate a UMG widget which is using the speficied Slate widget as its native implementation + * @param SW Slate widget + * @param Parent Parent widget under which the UMG widget should be found + * @return The UMG widget if found, otherwise nullptr + */ +UWidget* FindWidgetFromSlate(SWidget* SW, UWidget* Parent); + +/** + * @brief Set the focus to a given widget "properly", which means that if this is a widget derived + * from UFocusableWidget, it calls SetFocusProperly on it which allows a customised implementation. + * This is done because SetFocus() is not virtual and cannot be changed, and bIsFocusable seems to get + * reset by Slate code even if I try to set it and then override Native methods. + * @param Widget A UWidget + */ +void SetWidgetFocusProperly(UWidget* Widget); + +template +const T* GetPreferedActionOrAxisMapping(const TArray& AllMappings, const FName& Name, + EInputImageDevicePreference DevicePreference, + EInputMode LastInputDevice, + EInputMode LastButtonInputDevice, + EInputMode LastAxisInputDevice) +{ + const T* MouseMapping = nullptr; + const T* KeyboardMapping = nullptr; + const T* GamepadMapping = nullptr; + for (const T& ActionMap : AllMappings) + { + // notice how we take the LAST one in the list as the final version + // this is because UInputSettings::GetActionMappingByName *reverses* the mapping list from Project Settings + if (ActionMap.Key.IsGamepadKey()) + { + GamepadMapping = &ActionMap; + } + else if (ActionMap.Key.IsMouseButton()) // registers true for mouse axes too + { + MouseMapping = &ActionMap; + } + else + { + KeyboardMapping = &ActionMap; + } + } + + const T* Preferred = nullptr; + if (GamepadMapping && LastInputDevice == EInputMode::Gamepad) + { + // Always prefer gamepad if used last + Preferred = GamepadMapping; + } + else + { + switch (DevicePreference) + { + // Auto should be pre-converted to another + case EInputImageDevicePreference::Auto: + UE_LOG(LogStevesUI, Error, TEXT("Device Preference should have been converted before this call")) + break; + case EInputImageDevicePreference::Gamepad_Keyboard_Mouse: + Preferred = KeyboardMapping ? KeyboardMapping : MouseMapping; + break; + case EInputImageDevicePreference::Gamepad_Mouse_Keyboard: + Preferred = MouseMapping ? MouseMapping : KeyboardMapping; + break; + + case EInputImageDevicePreference::Gamepad_Keyboard_Mouse_Button: + // Use the latest button press + Preferred = (MouseMapping && (LastButtonInputDevice == EInputMode::Mouse || !KeyboardMapping)) ? MouseMapping : KeyboardMapping; + break; + case EInputImageDevicePreference::Gamepad_Keyboard_Mouse_Axis: + // Use the latest button press + Preferred = (MouseMapping && (LastAxisInputDevice == EInputMode::Mouse || !KeyboardMapping)) ? MouseMapping : KeyboardMapping; + break; + default: + break; + } + } + return Preferred; +} + + +const FKey* GetPreferedKeyMapping(const TArray& AllKeys, + EInputImageDevicePreference DevicePreference, + EInputMode LastInputDevice, + EInputMode LastButtonInputDevice, + EInputMode LastAxisInputDevice); diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/TabButton.cpp b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/TabButton.cpp new file mode 100644 index 00000000..da8c8a2b --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/TabButton.cpp @@ -0,0 +1,29 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +#include "StevesUI/TabButton.h" + +void UTabButton::Initialise(int InTabIndex,const FText& InLabel) +{ + TabIndex=InTabIndex; + SetTabLabel(InLabel); +} + +void UTabButton::SetTabLabel_Implementation(const FText& InLabel) +{ +} + +void UTabButton::SetTabSelected(bool bSelected) +{ + bTabSelected=bSelected; + RefreshForSelectionChanged(); +} + +void UTabButton::RefreshForSelectionChanged_Implementation() +{ + +} + +void UTabButton::NotifyTabSelected() +{ + OnTabSelected.Broadcast(TabIndex); +} diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/TabListWidget.cpp b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/TabListWidget.cpp new file mode 100644 index 00000000..131e23c8 --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/TabListWidget.cpp @@ -0,0 +1,165 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +#include "StevesUI/TabListWidget.h" +#include "Components/HorizontalBoxSlot.h" +#include "Components/WidgetSwitcher.h" +#include "StevesUI/TabButton.h" +#include "StevesUI/FocusableButton.h" +#include "StevesUI/MenuBase.h" +#include "Widgets/SPanel.h" + + +void UTabListWidget::SetTargetWidgetSwitcher(UWidgetSwitcher* WidgetSwitcher) +{ + TargetWidgetSwitcher=WidgetSwitcher; +} + +bool UTabListWidget::RegisterTab(const FName TabID, const TSubclassOf ButtonWidgetType,FText Label, + UWidget* ContentWidget) +{ + + if (RegisteredTabs.Contains(TabID)) + { + UE_LOG(LogTemp,Error,TEXT("UTabListWidget::RegisterTab Already Registered ID %s"),*TabID.ToString()); + return false; + } + if (TabButtonContainer==nullptr) + { + UE_LOG(LogTemp,Error,TEXT("UTabListWidget::RegisterTab TabButtonContainer is not set")); + return false; + } + FName const ButtonName( *FString::Printf(TEXT("TabButton_%s"),*TabID.ToString())); + UTabButton* const NewTabButton = Cast(CreateWidget(this,ButtonWidgetType,ButtonName)); + const int CurrentTabCount = RegisteredTabs.Num(); + const int NewTabIndex = CurrentTabCount; + + FTabListRegisteredTabInfo TabInfo; + TabInfo.TabIndex = NewTabIndex; + TabInfo.TabButton = NewTabButton; + TabInfo.ContentInstance = ContentWidget; + RegisteredTabs.Add(TabID, TabInfo); + UPanelSlot* ButtonPanelSlot=TabButtonContainer->AddChild(NewTabButton); + UHorizontalBoxSlot* HorizontalBoxSlot=Cast(ButtonPanelSlot); + if (HorizontalBoxSlot) + { + HorizontalBoxSlot->SetPadding(TabButtonPadding); + } + + NewTabButton->OnTabSelected.AddUniqueDynamic(this,&UTabListWidget::TabPressed); + NewTabButton->TabIndex=NewTabIndex; + NewTabButton->Initialise(NewTabIndex,Label); + + // If first tab added, select this + if (CurrentTabCount==0) + { + SelectTab(0); + } + return true; +} + +void UTabListWidget::TabPressed(int TabIndex) +{ + SelectTab(TabIndex); +} + + +bool UTabListWidget::GetTabDataForIndex(const int Index,FTabListRegisteredTabInfo& TabInfo) +{ + for (const auto& InfoPair : RegisteredTabs) + { + if (InfoPair.Value.TabIndex==Index) + { + TabInfo=InfoPair.Value; + return true; + } + } + return false; +} + +void UTabListWidget::IncrementTabIndex(int Amount) +{ + int NewTabIndex=SelectedTabIndex+Amount; + while (NewTabIndex<0) NewTabIndex+=RegisteredTabs.Num(); + NewTabIndex%=RegisteredTabs.Num(); + SelectTab(NewTabIndex); + +} +void UTabListWidget::SelectTab(int TabIndex) +{ + if (TabIndex==SelectedTabIndex) return; // No Change + + // If a tab button has previously been selected, deselect + if (SelectedTabIndex!=-1) + { + FTabListRegisteredTabInfo PreviousTabInfo; + if (GetTabDataForIndex(SelectedTabIndex,PreviousTabInfo)) + { + PreviousTabInfo.TabButton->SetTabSelected(false); + } else + { + UE_LOG(LogTemp,Warning,TEXT("Couldn't find button for previously index %i"),SelectedTabIndex); + } + } + SelectedTabIndex=TabIndex; + FTabListRegisteredTabInfo CurrentTabInfo; + if (GetTabDataForIndex(SelectedTabIndex,CurrentTabInfo)) + { + CurrentTabInfo.TabButton->SetTabSelected(true); + } + + if (!TargetWidgetSwitcher) + { + UE_LOG(LogTemp,Warning,TEXT("TargetWidgetSwitcher not set")); + return; + } + if (!CurrentTabInfo.ContentInstance) + { + UE_LOG(LogTemp,Warning,TEXT("Content not set for tab index %i"),SelectedTabIndex); + return; + } + + // If there is a current content widget, cache the current focus so that it can be restored when moving back to this tab + UWidget* PreviousContentWidget=TargetWidgetSwitcher->GetActiveWidget(); + if (PreviousContentWidget) + { + if (UFocusablePanel* TargetFocusableWidget=Cast(PreviousContentWidget)) + { + TargetFocusableWidget->SavePreviousFocus(); + } + } + + TargetWidgetSwitcher->SetActiveWidget(CurrentTabInfo.ContentInstance); + + // If this is an instance of UFocusableUserWidget, notify to activate default focus + if (UFocusableUserWidget* TargetFocusableWidget=Cast(CurrentTabInfo.ContentInstance)) + { + UE_LOG(LogTemp,Warning,TEXT("Focusable widget found for widget %s"),*CurrentTabInfo.ContentInstance->GetName()); + TargetFocusableWidget->SetFocusProperly(); + }else + { + UE_LOG(LogTemp,Warning,TEXT("Widget not focusable widget %s"),*CurrentTabInfo.ContentInstance->GetName()); + } + // Notify + OnContentWidgetChanged.Broadcast(PreviousContentWidget,CurrentTabInfo.ContentInstance); +} + +bool UTabListWidget::HandleKeyDownEvent(const FKeyEvent& InKeyEvent) +{ + Super::HandleKeyDownEvent(InKeyEvent); + if (!this->GetIsEnabled()) return false; // Ignore if not enabled + + const FKey Key = InKeyEvent.GetKey(); + if (PreviousTabKeys.Contains(Key)) + { + IncrementTabIndex(-1); + return true; + } + if (NextTabKeys.Contains(Key)) + { + IncrementTabIndex(1); + return true; + } + return false; +} + + diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/TypewriterTextWidget.cpp b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/TypewriterTextWidget.cpp new file mode 100644 index 00000000..c784ab26 --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesUI/TypewriterTextWidget.cpp @@ -0,0 +1,660 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +// Original Copyright (c) Sam Bloomberg https://github.com/redxdev/UnrealRichTextDialogueBox (MIT License) + +#include "StevesUI/TypewriterTextWidget.h" +#include "Engine/Font.h" +#include "Styling/SlateStyle.h" +#include "Widgets/Text/SRichTextBlock.h" +#include "Runtime/Launch/Resources/Version.h" +#if ENGINE_MAJOR_VERSION == 5 && ENGINE_MINOR_VERSION >= 7 +#include "Internationalization/TextChar.h" +#endif + +//PRAGMA_DISABLE_OPTIMIZATION + +void URichTextBlockForTypewriter::ReleaseSlateResources(bool bReleaseChildren) +{ + Super::ReleaseSlateResources(bReleaseChildren); + + TextLayout.Reset(); + TextMarshaller.Reset(); +} + +TSharedRef URichTextBlockForTypewriter::RebuildWidget() +{ + // Copied from URichTextBlock::RebuildWidget + UpdateStyleData(); + + TArray< TSharedRef< class ITextDecorator > > CreatedDecorators; + CreateDecorators(CreatedDecorators); + + TextMarshaller = FRichTextLayoutMarshaller::Create(CreateMarkupParser(), CreateMarkupWriter(), CreatedDecorators, StyleInstance.Get()); + + MyRichTextBlock = + SNew(SRichTextBlock) + .TextStyle(bOverrideDefaultStyle ? &GetDefaultTextStyleOverride() : &GetDefaultTextStyle()) + .Marshaller(TextMarshaller) + .CreateSlateTextLayout( + FCreateSlateTextLayout::CreateWeakLambda(this, [this] (SWidget* InOwner, const FTextBlockStyle& InDefaultTextStyle) mutable + { + TextLayout = FSlateTextLayout::Create(InOwner, InDefaultTextStyle); + return StaticCastSharedPtr(TextLayout).ToSharedRef(); + })); + + return MyRichTextBlock.ToSharedRef(); +} + +UTypewriterTextWidget::UTypewriterTextWidget(const FObjectInitializer& ObjectInitializer) : + Super(ObjectInitializer), LineText(nullptr), bHasMoreLineParts(0), SkipToLineEndCountdown(0) +{ + bHasFinishedPlaying = true; +} + + +void UTypewriterTextWidget::ClearLetterCountdownTimer() +{ + NextLetterCountdown = 0; +} + +void UTypewriterTextWidget::SetText(const FText& InText) +{ + if (IsValid(LineText)) + { + ClearLetterCountdownTimer(); + + LineText->SetText(InText); + + bHasFinishedPlaying = true; + } +} + +FText UTypewriterTextWidget::GetText() const +{ + if (IsValid(LineText)) + { + return LineText->GetText(); + } + + return FText(); +} + +void UTypewriterTextWidget::PlayLine(const FText& InLine, float Speed) +{ + CurrentLine = InLine; + RemainingLinePart = CurrentLine.ToString(); + PlayNextLinePart(Speed); +} + +void UTypewriterTextWidget::PlayNextLinePart(float Speed) +{ + check(GetWorld()); + + ClearLetterCountdownTimer(); + + CurrentRunName = ""; + CurrentLetterIndex = 0; + CachedLetterIndex = 0; + CurrentSegmentIndex = 0; + MaxLetterIndex = 0; + NumberOfLines = 0; + CombinedTextHeight = 0; + PauseTime = 0; + CurrentPlaySpeed = Speed; + Segments.Empty(); + CachedSegmentText.Empty(); + + if (RemainingLinePart.IsEmpty()) + { + if (IsValid(LineText)) + { + LineText->SetText(FText::GetEmpty()); + } + + bHasFinishedPlaying = true; + OnTypewriterLineFinished.Broadcast(this); + OnLineFinishedPlaying(); + + if (IsValid(LineText)) + { + LineText->SetVisibility(ESlateVisibility::Hidden); + } + + } + else + { + if (IsValid(LineText)) + { + LineText->SetText(FText::GetEmpty()); + LineText->SetVisibility(ESlateVisibility::Hidden); + } + + + bHasMoreLineParts = false; + bHasFinishedPlaying = false; + + if (bFirstPlayLine) + { + // Delay the very first PlayLine after construction, CalculateWrappedString is not reliable until a couple + // of UI geometry updates. At first the geometry is 0, then it's just wrong, and then finally it settles. + + StartPlayLineCountdown = 0.2f; + } + else + { + StartPlayLine(); + } + } +} + +void UTypewriterTextWidget::StartPlayLine() +{ + CalculateWrappedString(RemainingLinePart); + + if (MaxNumberOfLines > 0 && NumberOfLines > MaxNumberOfLines) + { + int MaxLength = CalculateMaxLength(); + int TerminatorIndex = FindLastTerminator(RemainingLinePart, MaxLength); + int Length = TerminatorIndex + 1; + const FString& FirstLinePart = RemainingLinePart.Left(Length); + + CalculateWrappedString(FirstLinePart); + + RemainingLinePart.RightChopInline(Length); + bHasMoreLineParts = true; + } + + // Clear the lines - this is needed to prevent an occasional visible version of all lines for a single frame + TSharedPtr Layout = LineText->GetTextLayout(); + Layout->ClearLines(); + + NextLetterCountdown = NextLetterCountdownInterval = LetterPlayTime / CurrentPlaySpeed; + + bFirstPlayLine = false; + + PlayNextLetter(); + +} + +void UTypewriterTextWidget::SkipToLineEnd() +{ + // Clear all timers + StartPlayLineCountdown = 0; + ClearLetterCountdownTimer(); + + CurrentLetterIndex = MaxLetterIndex - 1; + if (IsValid(LineText)) + { + LineText->SetText(FText::FromString(CalculateSegments(nullptr))); + } + + bHasFinishedPlaying = true; + OnTypewriterLineFinished.Broadcast(this); + OnLineFinishedPlaying(); +} + +void UTypewriterTextWidget::FindWordVowels(const FString& Word, TArray& VowelsPos) +{ + static const FString Vowels = FString(TEXT("iaeouyIAEOUIY")); + static const FString LetterY = FString(TEXT("yY")); + static const FString LetterE = FString(TEXT("eE")); + static const FString LetterR = FString(TEXT("rR")); + + int32 Unused; + int i = 0; + int32 WordLen = Word.Len(); + + if (LetterY.FindChar(Word[0], Unused)) // Trim at start if "y" is at the beginning of the word + { + i = 1; + } + if (WordLen > 3 && LetterE.FindChar(Word[WordLen - 1], Unused) && (Vowels. + FindChar(Word[WordLen - 3], Unused) || LetterR.FindChar(Word[WordLen - 3], Unused))) + // Trim at end if word ending with "vowel-*-e" or "r-*-e" + { + WordLen -= 1; + } + while (i < WordLen) + { + if (Vowels.FindChar(Word[i], Unused)) + { + VowelsPos.Add(i); + if (i+1 < WordLen && Vowels.FindChar(Word[i+1], Unused)) // 2 vowel letters in consecutive + { + ++i; + } + } + ++i; + } + if (VowelsPos.IsEmpty()) + { + VowelsPos.Add(Word.Len()/2); + } +} + +void UTypewriterTextWidget::NativeTick(const FGeometry& MyGeometry, float InDeltaTime) +{ + Super::NativeTick(MyGeometry, InDeltaTime); + + if (const UWorld* World = GetWorld()) + { + if (bPlayWhenPaused || !World->IsPaused()) + { + // For replacement of timers to allow to run when paused + if (NextLetterCountdown > 0) + { + NextLetterCountdown -= InDeltaTime; + if (NextLetterCountdown <= 0) + { + NextLetterCountdown = NextLetterCountdownInterval; // Reset countdown + PlayNextLetter(); + } + } + if (StartPlayLineCountdown > 0) + { + StartPlayLineCountdown -= InDeltaTime; + if (StartPlayLineCountdown <= 0) + { + StartPlayLineCountdown = 0; + StartPlayLine(); + } + } + if (SkipToLineEndCountdown > 0) + { + SkipToLineEndCountdown -= InDeltaTime; + if (SkipToLineEndCountdown <= 0) + { + SkipToLineEndCountdown = 0; + SkipToLineEnd(); + } + } + } + } + +} + +void UTypewriterTextWidget::NativeConstruct() +{ + Super::NativeConstruct(); + + bFirstPlayLine = true; +} + +int UTypewriterTextWidget::OnStartNewWord(const FString SegmentRemain) +{ + FString NewWord; + int i = 0; + while (i < SegmentRemain.Len()) + { + if (i == SegmentRemain.Len()-1) + { +#if ENGINE_MAJOR_VERSION == 5 && ENGINE_MINOR_VERSION >= 7 + if (FTextChar::IsWhitespace(SegmentRemain[i])) +#else + if (FText::IsWhitespace(SegmentRemain[i])) +#endif + { + bLastSegmentEndsWithBlank = true; + NewWord = SegmentRemain.Mid(0, i); + } + else + { + NewWord = SegmentRemain.Mid(0, i+1); + } + if (!NewWord.IsEmpty() && !(NewWord.Len() == 1 && IsPunctuation(NewWord[0]))) + { + OnTypewriterStartWord.Broadcast(this, NewWord); + } + break; + } +#if ENGINE_MAJOR_VERSION == 5 && ENGINE_MINOR_VERSION >= 7 + if (FTextChar::IsWhitespace(SegmentRemain[i])) +#else + if (FText::IsWhitespace(SegmentRemain[i])) +#endif + { + NewWord = SegmentRemain.Mid(0, i); + if (!NewWord.IsEmpty() && !(NewWord.Len() == 1 && IsPunctuation(NewWord[0]))) + { + OnTypewriterStartWord.Broadcast(this, NewWord); + } + break; + } + ++i; + } + return i; +} + +void UTypewriterTextWidget::PlayNextLetter() +{ + // Incorporate pauses as a multiple of play timer (may not be exact but close enough) + if (PauseTime > 0) + { + PauseTime -= LetterPlayTime/CurrentPlaySpeed; + if (PauseTime > 0) + return; + } + + FString NewRunName; + const FString WrappedString = CalculateSegments(&NewRunName); + if (IsValid(LineText)) + { + LineText->SetText(FText::FromString(WrappedString)); + if (WrappedString.Len()>1) // For some reason this causes issues when just one char + { + LineText->SetVisibility(ESlateVisibility::Visible); + } + } + + if (NewRunName != CurrentRunName) + { + CurrentRunName = NewRunName; + OnTypewriterRunNameChanged.Broadcast(this, NewRunName); + } + + OnPlayLetter(); + OnTypewriterLetterAdded.Broadcast(this, WrappedString.Right(1)); + + // TODO: How do we keep indexing of text i18n-friendly? + if (CurrentLetterIndex < MaxLetterIndex) + { + ++CurrentLetterIndex; + } + else + { + ClearLetterCountdownTimer(); + SkipToLineEndCountdown = EndHoldTime; + + } +} + +bool UTypewriterTextWidget::IsSentenceTerminator(TCHAR Letter) const +{ + int32 Unused; + return SentenceTerminators.FindChar(Letter, Unused); +} + +bool UTypewriterTextWidget::IsClauseTerminator(TCHAR Letter) const +{ + int32 Unused; + return ClauseTerminators.FindChar(Letter, Unused); +} + +bool UTypewriterTextWidget::IsPunctuation(TCHAR Letter) const +{ + static const FString Punctuations = FString(TEXT("!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~")); + int32 Unused; + return Punctuations.FindChar(Letter, Unused); +} + +int UTypewriterTextWidget::FindLastTerminator(const FString& CurrentLineString, int Count) const +{ + int TerminatorIndex = CurrentLineString.FindLastCharByPredicate([this](TCHAR Char) { return IsSentenceTerminator(Char); }, Count); + if (TerminatorIndex != INDEX_NONE) + { + return TerminatorIndex; + } + + TerminatorIndex = CurrentLineString.FindLastCharByPredicate([this](TCHAR Char) { return IsClauseTerminator(Char); }, Count); + if (TerminatorIndex != INDEX_NONE) + { + return TerminatorIndex; + } + + TerminatorIndex = CurrentLineString.FindLastCharByPredicate( +#if ENGINE_MAJOR_VERSION == 5 && ENGINE_MINOR_VERSION >= 7 + FTextChar::IsWhitespace, +#else + FText::IsWhitespace, +#endif + Count); + if (TerminatorIndex != INDEX_NONE) + { + return TerminatorIndex; + } + + return (Count - 1); +} + +int UTypewriterTextWidget::CalculateMaxLength() +{ + int MaxLength = 0; + int CurrentNumberOfLines = 1; + for (int i = 0; i < Segments.Num(); i++) + { + const FTypewriterTextSegment& Segment = Segments[i]; + MaxLength += Segment.Text.Len(); + if (Segment.Text.Equals(FString(TEXT("\n")))) + { + CurrentNumberOfLines++; + if (MaxNumberOfLines > 0 && CurrentNumberOfLines > MaxNumberOfLines) + { + break; + } + } + } + + return MaxLength; +} + +void UTypewriterTextWidget::CalculateWrappedString(const FString& CurrentLineString) +{ + // Rich Text views give you: + // - A blank block at the start for some reason + // - One block per line (newline characters stripped) + // - Split for different runs (decorators) + // - The newlines we add are the only newlines in the output so that's the number of lines + // If we've got here, that means the text isn't empty so 1 line at least + NumberOfLines = 1; + MaxLetterIndex = 0; + CombinedTextHeight = 0; + Segments.Empty(); + if (IsValid(LineText) && LineText->GetTextLayout().IsValid()) + { + TSharedPtr Layout = LineText->GetTextLayout(); + TSharedPtr Marshaller = LineText->GetTextMarshaller(); + + const FGeometry& TextBoxGeometry = LineText->GetCachedGeometry(); + const FVector2D TextBoxSize = TextBoxGeometry.GetLocalSize(); + + Layout->ClearLines(); + Layout->SetWrappingWidth(TextBoxSize.X); + Marshaller->SetText(CurrentLineString, *Layout.Get()); + Layout->UpdateLayout(); + + bool bHasWrittenText = false; + auto Views = Layout->GetLineViews(); + for (int v = 0; v < Views.Num(); ++v) + { + const FTextLayout::FLineView& View = Views[v]; + + for (int b = 0; b < View.Blocks.Num(); ++b) + { + TSharedRef Block = View.Blocks[b]; + TSharedRef Run = Block->GetRun(); + + FTypewriterTextSegment Segment; + Run->AppendTextTo(Segment.Text, Block->GetTextRange()); + + // HACK: For some reason image decorators (and possibly other decorators that don't + // have actual text inside them) result in the run containing a zero width space instead of + // nothing. This messes up our checks for whether the text is empty or not, which doesn't + // have an effect on image decorators but might cause issues for other custom ones. + // Instead of emptying text, which might have some unknown effect, just mark it as empty + const bool bTextIsEmpty = Segment.Text.IsEmpty() || + (Segment.Text.Len() == 1 && Segment.Text[0] == 0x200B); + const int TextLen = bTextIsEmpty ? 0 : Segment.Text.Len(); + const bool bRunIsEmpty = Segment.RunInfo.Name.IsEmpty(); + + Segment.RunInfo = Run->GetRunInfo(); + Segments.Add(Segment); + + // A segment with a named run should still take up time for the typewriter effect. + MaxLetterIndex += FMath::Max(TextLen, Segment.RunInfo.Name.IsEmpty() ? 0 : 1); + + if (!bTextIsEmpty || !bRunIsEmpty) + { + bHasWrittenText = true; + } + } + + if (bHasWrittenText) + { + CombinedTextHeight += View.TextHeight; + } + // Add check for an unnecessary newline after ever line even if there's nothing else to do, otherwise + // we end up inserting a newline after a simple single line of text + const bool bHasMoreText = v < (Views.Num() - 1); + if (bHasWrittenText && bHasMoreText) + { + Segments.Add(FTypewriterTextSegment{TEXT("\n")}); + ++NumberOfLines; + ++MaxLetterIndex; + } + } + + Layout->SetWrappingWidth(0); + + // Set the desired vertical size so that we're already the size we need to accommodate all lines + // Without this, a flexible box size will grow as lines are added + FVector2D Sz = GetMinimumDesiredSize(); + Sz.Y = CombinedTextHeight; + SetMinimumDesiredSize(Sz); + + LineText->SetText(LineText->GetText()); + } + else + { + Segments.Add(FTypewriterTextSegment{CurrentLineString}); + MaxLetterIndex = Segments[0].Text.Len(); + } + +} + +FString UTypewriterTextWidget::CalculateSegments(FString* OutCurrentRunName) +{ + FString Result = CachedSegmentText; + + int32 Idx = CachedLetterIndex; + while (Idx <= CurrentLetterIndex && CurrentSegmentIndex < Segments.Num()) + { + const FTypewriterTextSegment& Segment = Segments[CurrentSegmentIndex]; + if (!Segment.RunInfo.Name.IsEmpty()) + { + Result += FString::Printf(TEXT("<%s"), *Segment.RunInfo.Name); + + if (Segment.RunInfo.MetaData.Num() > 0) + { + for (const TTuple& MetaData : Segment.RunInfo.MetaData) + { + Result += FString::Printf(TEXT(" %s=\"%s\""), *MetaData.Key, *MetaData.Value); + } + } + + if (Segment.Text.IsEmpty()) + { + Result += TEXT("/>"); + ++Idx; // This still takes up an index for the typewriter effect. + } + else + { + Result += TEXT(">"); + } + } + + bool bIsSegmentComplete = true; + if (!Segment.Text.IsEmpty()) + { + int32 LettersLeft = CurrentLetterIndex - Idx + 1; + bIsSegmentComplete = LettersLeft >= Segment.Text.Len(); + LettersLeft = FMath::Min(LettersLeft, Segment.Text.Len()); + Idx += LettersLeft; + + Result += Segment.Text.Mid(0, LettersLeft); + + if (bNewWordEvent) + { + if (LettersLeft == 1) + { + if (CurrentSegmentIndex == 0) // First letter in a line + { + NextBlankLetterLeft = LettersLeft + OnStartNewWord(Segment.Text.Mid(LettersLeft-1, Segment.Text.Len()-LettersLeft+1)); + } +#if ENGINE_MAJOR_VERSION == 5 && ENGINE_MINOR_VERSION >= 7 + if (bLastSegmentEndsWithBlank && !FTextChar::IsWhitespace(Segment.Text[LettersLeft-1])) +#else + if (bLastSegmentEndsWithBlank && !FText::IsWhitespace(Segment.Text[LettersLeft-1])) +#endif + { + bLastSegmentEndsWithBlank = false; + NextBlankLetterLeft = LettersLeft + OnStartNewWord(Segment.Text.Mid(LettersLeft-1, Segment.Text.Len()-LettersLeft+1)); + } + } + if (LettersLeft-1 == NextBlankLetterLeft) + { +#if ENGINE_MAJOR_VERSION == 5 && ENGINE_MINOR_VERSION >= 7 + if (!FTextChar::IsWhitespace(Segment.Text[LettersLeft-1])) // Current letter is not a blank +#else + if (!FText::IsWhitespace(Segment.Text[LettersLeft-1])) // Current letter is not a blank +#endif + { + NextBlankLetterLeft = LettersLeft + OnStartNewWord(Segment.Text.Mid(LettersLeft-1, Segment.Text.Len()-LettersLeft+1)); + } + else + { + NextBlankLetterLeft = LettersLeft; + } + } + } + + // Add pause for sentence ends + if (Result.Len() > 0 && + IsSentenceTerminator(Result[Result.Len() - 1]) && + CurrentLetterIndex < MaxLetterIndex - 1) // Don't pause on the last letter, that's the end pause's job + { + // Look ahead to make sure we only pause on LAST sentence terminator in a chain of them, + // and also optionally not if there isn't whitespace after (e.g. to not pause on ".txt") + if (LettersLeft < Segment.Text.Len()) + { +#if ENGINE_MAJOR_VERSION == 5 && ENGINE_MINOR_VERSION >= 7 + const bool bIsWhitespace = FTextChar::IsWhitespace(Segment.Text[LettersLeft]); +#else + const bool bIsWhitespace = FText::IsWhitespace(Segment.Text[LettersLeft]); +#endif + + if (!IsSentenceTerminator(Segment.Text[LettersLeft]) && + (!bPauseOnlyIfWhitespaceFollowsSentenceTerminator || bIsWhitespace)) + { + PauseTime = PauseTimeAtSentenceTerminators; + } + } + } + + if (!Segment.RunInfo.Name.IsEmpty()) + { + Result += TEXT(""); + } + if (OutCurrentRunName) + { + *OutCurrentRunName = Segment.RunInfo.Name; + } + + } + + if (bIsSegmentComplete) + { + CachedLetterIndex = Idx; + CachedSegmentText = Result; + ++CurrentSegmentIndex; + NextBlankLetterLeft = 0; + } + else + { + break; + } + } + + return Result; +} + +//PRAGMA_ENABLE_OPTIMIZATION \ No newline at end of file diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesVisualLogger.cpp b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesVisualLogger.cpp new file mode 100644 index 00000000..af411e24 --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Private/StevesVisualLogger.cpp @@ -0,0 +1,35 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +#include "StevesVisualLogger.h" +#include "Runtime/Launch/Resources/Version.h" +#include "EngineStats.h" +#include "Stats/Stats.h" + + +void FStevesVisualLogger::InternalPolyLogfImpl(const UObject* Object, + const FLogCategoryBase& Category, + ELogVerbosity::Type Verbosity, + const TArray& Points, + const FColor& Color, + const uint16 Thickness) +{ +#if ENABLE_VISUAL_LOG + const FName CategoryName = Category.GetCategoryName(); + + SCOPE_CYCLE_COUNTER(STAT_VisualLog); \ + UWorld *World = nullptr; \ + FVisualLogEntry *CurrentEntry = nullptr; \ + if (FVisualLogger::CheckVisualLogInputInternal(Object, CategoryName, Verbosity, &World, &CurrentEntry) == false) + { + return; + } + + // EVisualLoggerShapeElement::Path doesn't support a label, so description is always blank +#if ENGINE_MAJOR_VERSION == 5 && ENGINE_MINOR_VERSION >= 4 + CurrentEntry->AddPath(Points, CategoryName, Verbosity, Color, "", Thickness); +#else + CurrentEntry->AddElement(Points, CategoryName, Verbosity, Color, "", Thickness); +#endif + +#endif +} diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesAssetHelpers.h b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesAssetHelpers.h new file mode 100644 index 00000000..4513b655 --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesAssetHelpers.h @@ -0,0 +1,57 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +#pragma once + +#include "CoreMinimal.h" +#include "Kismet/BlueprintFunctionLibrary.h" +#include "StevesAssetHelpers.generated.h" + +class UObjectLibrary; + +/// Class to help out with asset related tasks, mostly C++ only but defined as a BPL in case that's useful later +UCLASS() +class STEVESUEHELPERS_API UStevesAssetHelpers : public UBlueprintFunctionLibrary +{ + GENERATED_BODY() + +public: + /** + * Find the soft object paths of blueprints matching an object library definition, in a given set of paths. + * @param InPaths The asset paths to search. Must be game-directory form e.g. /Game/Data + * @param ObjectLibrary The object library which defines the (super)class which you're looking for. You should + * have created this similar to: `UObjectLibrary::CreateLibrary(UYourAssetClass::StaticClass(), true, GIsEditor && !IsRunningCommandlet());` + * @param OutSoftPaths Output array of soft object paths to the blueprints found. You can resolve these using their ResolveObject() function. + * @returns The number of blueprint assets found + */ + static int FindBlueprintSoftPaths(const TArray& InPaths, UObjectLibrary* ObjectLibrary, TArray& OutSoftPaths); + /** + * Find the soft object paths of blueprints matching an object library definition, in a given set of paths. + * @param InPaths The asset paths to search. Must be game-directory form e.g. /Game/Data + * @param ObjectLibrary The object library which defines the (super)class which you're looking for. You should + * have created this similar to: `UObjectLibrary::CreateLibrary(UYourAssetClass::StaticClass(), true, GIsEditor && !IsRunningCommandlet());` + * @param OutSoftPaths Output array of soft object paths to the blueprints found. You can resolve these using their ResolveObject() function. + * @returns The number of blueprint assets found + */ + static int FindBlueprintSoftPaths(const TArray& InPaths, UObjectLibrary* ObjectLibrary, TArray& OutSoftPaths); + + /** + * Find a list of loaded classes for blueprints matching an object library definition, in a given set of paths. + * @param InPaths The asset paths to search. Must be game-directory form e.g. /Game/Data + * @param ObjectLibrary The object library which defines the (super)class which you're looking for. You should + * have created this similar to: `UObjectLibrary::CreateLibrary(UYourAssetClass::StaticClass(), true, GIsEditor && !IsRunningCommandlet());` + * @param OutClasses Output array of loaded UClass objects representing the blueprints found. + * @returns The number of blueprint assets found + */ + static int FindBlueprintClasses(const TArray& InPaths, UObjectLibrary* ObjectLibrary, TArray& OutClasses); + /** + * Find the soft object paths of blueprints matching an object library definition, in a given set of paths. + * @param InPaths The asset paths to search. Must be game-directory form e.g. /Game/Data + * @param ObjectLibrary The object library which defines the (super)class which you're looking for. You should + * have created this similar to: `UObjectLibrary::CreateLibrary(UYourAssetClass::StaticClass(), true, GIsEditor && !IsRunningCommandlet());` + * @param OutClasses Output array of loaded UClass objects representing the blueprints found. + * @returns The number of blueprint assets found + */ + static int FindBlueprintClasses(const TArray& InPaths, UObjectLibrary* ObjectLibrary, TArray& OutClasses); + + +}; diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesBPL.h b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesBPL.h new file mode 100644 index 00000000..36060175 --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesBPL.h @@ -0,0 +1,139 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license + +#pragma once + +#include "CoreMinimal.h" +#include "StevesBalancedRandomStream.h" +#include "Kismet/BlueprintFunctionLibrary.h" + +#include "StevesMathHelpers.h" +#include "Components/PanelSlot.h" +#include "StevesBPL.generated.h" + +class UPanelWidget; +class UWidget; +/** + * Blueprint library exposing various things in a Blueprint-friendly way e.g. using by-value FVectors so they can + * be entered directly if required, rather than const& as in C++. Also use degrees not radians. + */ +UCLASS() +class STEVESUEHELPERS_API UStevesBPL : public UBlueprintFunctionLibrary +{ + GENERATED_BODY() + +public: + /** + * Return whether a sphere overlaps a cone + * @param ConeOrigin Origin of the cone + * @param ConeDir Direction of the cone, must be normalised + * @param ConeAngle Angle of the cone, in degrees + * @param Distance Length of the cone + * @param SphereCentre Centre of the sphere + * @param SphereRadius Radius of the sphere + * @return True if the sphere overlaps the cone + */ + UFUNCTION(BlueprintCallable, Category="StevesUEHelpers|Math") + static bool SphereOverlapCone(FVector ConeOrigin, FVector ConeDir, float ConeAngle, float Distance, FVector SphereCentre, float SphereRadius) + { + return StevesMathHelpers::SphereOverlapCone(ConeOrigin, ConeDir, FMath::DegreesToRadians(ConeAngle*0.5f), Distance, SphereCentre, SphereRadius); + } + + + + /** + * Set the focus to a given widget "properly", which means that if this is a widget derived + * from UFocusableWidget, it calls SetFocusProperly on it which allows a customised implementation. + * @param Widget The widget to give focus to + */ + UFUNCTION(BlueprintCallable, Category="StevesUEHelpers|UI") + static void SetWidgetFocus(UWidget* Widget); + + /** + * Insert a child widget at a specific index + * @param Parent The container widget + * @param Child The child widget to add + * @param AtIndex The index at which the new child should exist + * @returns The slot the child was inserted at + */ + UFUNCTION(BlueprintCallable, Category="StevesUEHelpers|UI") + static UPanelSlot* InsertChildWidgetAt(UPanelWidget* Parent, UWidget* Child, int AtIndex = 0); + + UFUNCTION(BlueprintPure, Category="StevesUEHelpers|Random", meta=(NativeMakeFunc)) + static FStevesBalancedRandomStream MakeBalancedRandomStream(int64 Seed); + + UFUNCTION(BlueprintCallable, Category="StevesUEHelpers|Random") + static float BalancedRandom(FStevesBalancedRandomStream& Stream) { return Stream.Rand(); } + + UFUNCTION(BlueprintCallable, Category="StevesUEHelpers|Random") + static FVector2D BalancedRandom2D(FStevesBalancedRandomStream& Stream) { return Stream.Rand2D(); } + + UFUNCTION(BlueprintCallable, Category="StevesUEHelpers|Random") + static FVector BalancedRandom3D(FStevesBalancedRandomStream& Stream) { return Stream.Rand3D(); } + + UFUNCTION(BlueprintCallable, Category="StevesUEHelpers|Random") + static FVector BalancedRandomVector(FStevesBalancedRandomStream& Stream) { return Stream.RandUnitVector(); } + + UFUNCTION(BlueprintCallable, Category="StevesUEHelpers|Random") + static FVector BalancedRandomPointInBox(FStevesBalancedRandomStream& Stream, const FVector& Min, const FVector& Max) { return Stream.RandPointInBox(FBox(Min, Max)); } + + + /** + * Let the content streaming system know that there is a viewpoint other than a possessed camera that should be taken + * into account when deciding what to stream in. This can be useful when you're using a scene capture component, + * which if it's capturing a scene that isn't close to a player, can result in blurry textures. + * @param ViewOrigin The world space view point + * @param ScreenWidth The width in pixels of the screen being rendered + * @param FOV Horizontal field of view, in degrees + * @param BoostFactor How much to boost the LOD by (1 being normal, higher being higher detail) + * @param bOverrideLocation Whether this is an override location, which forces the streaming system to ignore all other regular locations + * @param Duration How long the streaming system should keep checking this location (in seconds). 0 means just for the next Tick. + * @param ActorToBoost Optional pointer to an actor who's textures should have their streaming priority boosted + */ + UFUNCTION(BlueprintCallable, Category="StevesUEHelpers|Streaming") + static void AddViewOriginToStreaming(const FVector& ViewOrigin, + float ScreenWidth, + float FOV, + float BoostFactor = 1.0f, + bool bOverrideLocation = false, + float Duration = 0.0f, + AActor* ActorToBoost = nullptr); + + + /** + * Force content streaming to update. Useful if you need things to stream in sooner than usual. + * @param DeltaTime The amount of time to tell the streaming system that has passed. + * @param bBlockUntilDone If true, this call will not return until the streaming system has updated + */ + UFUNCTION(BlueprintCallable, Category="StevesUEHelpers|Streaming") + static void UpdateStreaming(float DeltaTime, bool bBlockUntilDone = false); + + /// Calculate the perceived luminance of a colour using ITU BT.709 standard + UFUNCTION(BlueprintCallable, Category="StevesUEHelpers|Colour") + static float GetPerceivedLuminance(const FLinearColor& InColour); + + /// Calculate the perceived luminance of a colour using ITU BT.601 standard (more R & B) + UFUNCTION(BlueprintCallable, Category="StevesUEHelpers|Colour") + static float GetPerceivedLuminance2(const FLinearColor& InColour); + + + /** + * Equivalent to FVector::HeadingAngle but for FVector2D + * @param Dir Input direction in 2D, does not need to be normalised + * @return 'Heading' angle between +/-PI. 0 is pointing down +X. + */ + UFUNCTION(BlueprintPure, Category="StevesUEHelpers|Math") + static float HeadingAngle2D(const FVector2D& Dir); + + /** + * Find the angle between two 2D vectors + * @param DirA Input direction in 2D, does not need to be normalised + * @param DirB Input direction in 2D, does not need to be normalised + * @return Difference in heading angle, between +/-PI, positive is anti-clockwise. 0 means directions match. + */ + UFUNCTION(BlueprintPure, Category="StevesUEHelpers|Math") + static float AngleBetween2D(const FVector2D& DirA, const FVector2D& DirB); + + UFUNCTION(BlueprintPure, Category="StevesUEHelpers|Project") + static FString GetProjectVersion(); +}; diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesBalancedRandomStream.h b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesBalancedRandomStream.h new file mode 100644 index 00000000..ccc479e9 --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesBalancedRandomStream.h @@ -0,0 +1,929 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +#pragma once + +#include "CoreMinimal.h" +#include "Math/Halton.h" +#include "StevesBalancedRandomStream.generated.h" + +// Credit to Andrew Wilmott for lots of the algorithms here +// See https://github.com/andrewwillmott/distribute-lib +// Used under Unlicense + +namespace StevesRandConstants +{ + constexpr uint32 kSafeMaxSeed2D = 43046721 - 1; + constexpr uint32 kSafeMaxSeed3D = 9765625 - 1; + constexpr float kOneOverThree = 1.0f / 3.0f; + constexpr float kOneOverFive = 1.0f / 5.0f; + +} + +/// "Balanced" random stream, using the Halton Sequence +/// This is deterministic and more uniform in appearance than a general random stream (although not perfectly uniform) +/// This is a generic stream which can do 1D, 2D and 3D sequences. If you only need a single type, it's +/// more efficient to use FStevesBalancedRandomStream1D/2D/3D +USTRUCT(BlueprintType) +struct STEVESUEHELPERS_API FStevesBalancedRandomStream +{ + GENERATED_BODY() + +protected: + uint32 InitialSeed = 0; + uint32 Seed = 0; + uint32 Base3Seed = 0; + uint32 Base5Seed = 0; + + static int32 SafeSeed(uint32 InSeed) + { + // Halton sequence gets unstable when seed gets too high, especially with higher bases + while (InSeed > StevesRandConstants::kSafeMaxSeed3D) + { + InSeed -= StevesRandConstants::kSafeMaxSeed3D; + } + return InSeed; + } + + void UpdateSeeds() + { + // For simplicity this version just derives the other seeds from the main one rather than + // calculating the sequence value at the same time like Andrew's does + Base3Seed = 0; + + // Average iterations: 1.5 + for (int i = 0, k = Seed; k; i += 2, k /= 3) + { + const int d = (k % 3); + Base3Seed |= d << i; + } + + Base5Seed = 0; + + // Average iterations: 2.5 + for (int i = 0, k = Seed; k; i += 3, k /= 5) + { + const int d = (k % 5); + Base5Seed |= d << i; + } + } + + uint32 SafeSeedInc() + { + Seed = SafeSeed(Seed + 1); + + UpdateSeeds(); + return Seed; + } + +public: + + FStevesBalancedRandomStream() + : InitialSeed(0) + , Seed(0) + { } + + /** + * Creates and initializes a new random stream from the specified seed value. + * + * @param InSeed The seed value. + */ + FStevesBalancedRandomStream( uint32 InSeed ) + { + Initialize(InSeed); + } + + /** + * Creates and initializes a new random stream from the specified name. + * + * @note If NAME_None is provided, the stream will be seeded using the current time. + * @param InName The name value from which the stream will be initialized. + */ + FStevesBalancedRandomStream( FName InName ) + { + Initialize(InName); + } + + /** + * Initializes this random stream with the specified seed value. + * + * @param InSeed The seed value. + */ + void Initialize( uint32 InSeed ) + { + InitialSeed = SafeSeed(InSeed); + Seed = InitialSeed; + + UpdateSeeds(); + } + + /** + * Initializes this random stream using the specified name. + * + * @note If NAME_None is provided, the stream will be seeded using the current time. + * @param InName The name value from which the stream will be initialized. + */ + void Initialize( FName InName ) + { + uint32 StartSeed; + if (InName != NAME_None) + { + StartSeed = GetTypeHash(InName.ToString()); + } + else + { + StartSeed = FPlatformTime::Cycles(); + } + Initialize(StartSeed); + } + + /** + * Resets this random stream to the initial seed value. + */ + void Reset() + { + Initialize(InitialSeed); + } + + uint32 GetInitialSeed() const + { + return InitialSeed; + } + + /** + * Generates a new random seed. + */ + void GenerateNewSeed() + { + Initialize(SafeSeed(FMath::Rand())); + } + + + /// Return a value between 0..1, inclusive + float Rand() + { + return Halton(SafeSeedInc(), 2); + } + + /// Return a 2D value with each element between 0..1, inclusive + /// Use this rather than calling Rand() twice to ensure balanced distribution + FVector2D Rand2D() + { + const float X = Halton(Seed, 2); + const float Y = Halton(Base3Seed, 3); + + SafeSeedInc(); + + return FVector2D(X, Y); + } + + /// Return a 3D value with each element between 0..1, inclusive + /// Use this rather than calling Rand() twice to ensure balanced distribution + FVector Rand3D() + { + const float X = Halton(Seed, 2); + const float Y = Halton(Base3Seed, 3); + const float Z = Halton(Base5Seed, 5); + SafeSeedInc(); + return FVector(X, Y, Z); + } + + /** + * Returns a random vector of unit size. + * + * @return Random unit vector. + */ + FVector RandUnitVector() + { + const FVector2D PitchYaw = Rand2D(); + return FRotator(PitchYaw.X, PitchYaw.Y, 0).RotateVector(FVector::UpVector); + } + + /// Random point in a 3D box + FORCEINLINE FVector RandPointInBox(const FBox& Box) + { + const FVector R3 = Rand3D(); + return FVector(FMath::Lerp(Box.Min.X, Box.Max.X, R3.X), + FMath::Lerp(Box.Min.Y, Box.Max.Y, R3.Y), + FMath::Lerp(Box.Min.Z, Box.Max.Z, R3.Z)); + } + + /// Random point in a 2D rectangle + FORCEINLINE FVector2D RandPointInBox2D(const FBox2D& Rect) + { + const FVector2D R2 = Rand2D(); + return FVector2D(FMath::Lerp(Rect.Min.X, Rect.Max.X, R2.X), + FMath::Lerp(Rect.Min.Y, Rect.Max.Y, R2.Y)); + } + + /// Random point in a circle + FORCEINLINE FVector2D RandPointInCircle(float Radius = 1.0) + { + // Just use rejection sampling for simplicity / speed + while (true) + { + const FVector2D Candidate = Rand2D(); + if (Candidate.SquaredLength() <= 1.0) + { + return Candidate * Radius; + } + } + } + + /// Random point in a sphere + FORCEINLINE FVector RandPointInSphere(float Radius = 1.0) + { + // Just use rejection sampling for simplicity / speed + while (true) + { + const FVector Candidate = Rand3D(); + if (Candidate.SquaredLength() <= 1.0) + { + return Candidate * Radius; + } + } + } + + /// Random value in a range (inclusive) + float RandRange(float Min, float Max) + { + return FMath::Lerp(Min, Max, Rand()); + } + + /// Random colour value + FLinearColor RandColour(const FLinearColor& From, const FLinearColor& To) + { + return FLinearColor::LerpUsingHSV(From, To, Rand()); + } + + /** + * Gets the current seed. + * + * @return Current seed. + */ + uint32 GetCurrentSeed() const + { + return Seed; + } + + + FString ToString() const + { + return FString::Printf(TEXT("FStevesBalancedRandomStream(InitialSeed=%u, Seed=%u)"), InitialSeed, Seed); + } + + +}; + +/// "Balanced" random stream, using the Halton Sequence, one dimension only (more efficient for this than FStevesBalancedRandomStream) +/// This is deterministic and more uniform in appearance than a general random stream (although not perfectly uniform) +USTRUCT(BlueprintType) +struct STEVESUEHELPERS_API FStevesBalancedRandomStream1D +{ + GENERATED_BODY() + +protected: + uint32 InitialSeed = 0; + uint32 Seed = 0; + +public: + + FStevesBalancedRandomStream1D() + { } + + /** + * Creates and initializes a new random stream from the specified seed value. + * + * @param InSeed The seed value. + */ + FStevesBalancedRandomStream1D( uint32 InSeed ) + { + Initialize(InSeed); + } + + /** + * Creates and initializes a new random stream from the specified name. + * + * @note If NAME_None is provided, the stream will be seeded using the current time. + * @param InName The name value from which the stream will be initialized. + */ + FStevesBalancedRandomStream1D( FName InName ) + { + Initialize(InName); + } + + /** + * Initializes this random stream with the specified seed value. + * + * @param InSeed The seed value. + */ + void Initialize( uint32 InSeed ) + { + InitialSeed = Seed = InSeed; + } + + /** + * Initializes this random stream using the specified name. + * + * @note If NAME_None is provided, the stream will be seeded using the current time. + * @param InName The name value from which the stream will be initialized. + */ + void Initialize( FName InName ) + { + uint32 StartSeed; + if (InName != NAME_None) + { + StartSeed = GetTypeHash(InName.ToString()); + } + else + { + StartSeed = FPlatformTime::Cycles(); + } + Initialize(StartSeed); + } + + /** + * Resets this random stream to the initial seed value. + */ + void Reset() + { + Initialize(InitialSeed); + } + + uint32 GetInitialSeed() const + { + return InitialSeed; + } + + /** + * Generates a new random seed. + */ + void GenerateNewSeed() + { + Initialize(FMath::Rand()); + } + + + /// Return a value between 0..1, inclusive + FORCEINLINE float Rand() + { + return Halton(Seed++, 2); + } + + /** + * Helper function for rand implementations. + * + * @return A random number in [0..A) + */ + FORCEINLINE int32 RandHelper( int32 A ) + { + // GetFraction guarantees a result in the [0,1) range. + return ((A > 0) ? FMath::TruncToInt(Rand() * float(A)) : 0); + } + + /// Random float value in a range (inclusive) + FORCEINLINE float RandRange(float Min, float Max) + { + return FMath::Lerp(Min, Max, Rand()); + } + + /// Random int value in a range (inclusive) + FORCEINLINE int32 RandRange( int32 Min, int32 Max ) + { + const int32 Range = (Max - Min) + 1; + + return Min + RandHelper(Range); + } + + /// Random colour value + FORCEINLINE FLinearColor RandColour(const FLinearColor& From, const FLinearColor& To) + { + return FLinearColor::LerpUsingHSV(From, To, Rand()); + } + + /** + * Gets the current seed. + * + * @return Current seed. + */ + FORCEINLINE uint32 GetCurrentSeed() const + { + return Seed; + } + + + FString ToString() const + { + return FString::Printf(TEXT("FStevesBalancedRandomStream1D(InitialSeed=%u, Seed=%u)"), InitialSeed, Seed); + } + +}; + + +/// "Balanced" 2D random stream, using the Halton Sequence. More efficient than the general FStevesBalancedRandomStream for 2D work +/// This is deterministic and more uniform in appearance than a general random stream (although not perfectly uniform) +USTRUCT(BlueprintType) +struct STEVESUEHELPERS_API FStevesBalancedRandomStream2D +{ + GENERATED_BODY() + +protected: + uint32 InitialSeed = 0; + uint32 Base2Seed = 0; + uint32 Base3Seed = 0; + + + FVector2f CurrentValue = FVector2f::ZeroVector; + + +public: + + FStevesBalancedRandomStream2D() + { + Initialize(0); + } + + /** + * Creates and initializes a new random stream from the specified seed value. + * + * @param InSeed The seed value. + */ + FStevesBalancedRandomStream2D( uint32 InSeed ) + { + Initialize(InSeed); + } + + /** + * Creates and initializes a new random stream from the specified name. + * + * @note If NAME_None is provided, the stream will be seeded using the current time. + * @param InName The name value from which the stream will be initialized. + */ + FStevesBalancedRandomStream2D( FName InName ) + { + Initialize(InName); + } + + /** + * Initializes this random stream with the specified seed value. + * + * @param InSeed The seed value. + */ + void Initialize( uint32 InSeed ) + { + // Halton sequence gets unstable when seed gets too high, especially with higher bases + while (InSeed >= StevesRandConstants::kSafeMaxSeed2D) + { + InSeed -= StevesRandConstants::kSafeMaxSeed2D; + } + + InitialSeed = Base2Seed = InSeed; + + CurrentValue.X = Halton(Base2Seed, 2); + + CurrentValue.Y = 0; + Base3Seed = 0; + + float ip = StevesRandConstants::kOneOverThree; + float p = ip; + + for (int i = 0, k = Base2Seed; k; i += 2, k /= 3) + { + int d = (k % 3); + Base3Seed |= d << i; + CurrentValue.Y += d * p; + p *= ip; + } + } + + /** + * Initializes this random stream using the specified name. + * + * @note If NAME_None is provided, the stream will be seeded using the current time. + * @param InName The name value from which the stream will be initialized. + */ + void Initialize( FName InName ) + { + uint32 StartSeed; + if (InName != NAME_None) + { + StartSeed = GetTypeHash(InName.ToString()); + } + else + { + StartSeed = FPlatformTime::Cycles(); + } + Initialize(StartSeed); + } + + /** + * Resets this random stream to the initial seed value. + */ + void Reset() + { + Initialize(InitialSeed); + } + + uint32 GetInitialSeed() const + { + return InitialSeed; + } + + /** + * Generates a new random seed. + */ + void GenerateNewSeed() + { + Initialize(FMath::Rand()); + } + + /// Return a 2D value with each element between 0..1, inclusive + FVector2f Rand2D() + { + // Wrap back to 0 at end of safe range + if (Base2Seed >= StevesRandConstants::kSafeMaxSeed2D) + { + Initialize(0); + } + + // This uses Andrew Wilmott's approach of calculating the next value at the same time as incrementing + // We calculate the new value while initialising / incrementing, so it's currently correct + const FVector2f Ret = CurrentValue; + + ///////////////////////////////////// + // base 2 + + uint32_t OldBase2Seed = Base2Seed; + Base2Seed++; + uint32_t Diff = Base2Seed ^ OldBase2Seed; + + // bottom bit always changes, higher bits + // change less frequently. + float s = 0.5f; + + // Diff will be of the form 0 * 1 +, i.e. one bits up until the last carry. + // expected iterations = 1 + 0.5 + 0.25 + ... = 2 + do + { + if (OldBase2Seed & 1) + CurrentValue.X -= s; + else + CurrentValue.X += s; + + s *= 0.5f; + + Diff = Diff >> 1; + OldBase2Seed = OldBase2Seed >> 1; + } + while (Diff); + + + ///////////////////////////////////// + // base 3: use 2 bits for each base 3 digit. + + uint32_t Mask = 0x3; // also the max base 3 digit + uint32_t Add = 0x1; // amount to Add to force carry once digit==3 + s = StevesRandConstants::kOneOverThree; + + Base3Seed++; + + // expected iterations: 1.5 + while (true) + { + if ((Base3Seed & Mask) == Mask) + { + Base3Seed += Add; // force carry into next 2-bit digit + CurrentValue.Y -= 2 * s; + + Mask = Mask << 2; + Add = Add << 2; + + s *= StevesRandConstants::kOneOverThree; + } + else + { + CurrentValue.Y += s; // we know digit n has gone from a to a + 1 + break; + } + } + + return Ret; + } + + /** + * Returns a random vector of unit size. + * + * @return Random unit vector. + */ + FVector RandUnitVector() + { + const FVector2f PitchYaw = Rand2D(); + return FRotator(PitchYaw.X, PitchYaw.Y, 0).RotateVector(FVector::UpVector); + } + + + /// Random point in a 2D rectangle + FORCEINLINE FVector2f RandPointInBox2D(const FBox2D& Rect) + { + const FVector2f R2 = Rand2D(); + return FVector2f(FMath::Lerp(Rect.Min.X, Rect.Max.X, R2.X), + FMath::Lerp(Rect.Min.Y, Rect.Max.Y, R2.Y)); + } + + /// Random point in a circle + FORCEINLINE FVector2f RandPointInCircle(float Radius = 1.0) + { + // Just use rejection sampling for simplicity / speed + while (true) + { + const FVector2f Candidate = Rand2D(); + if (Candidate.SquaredLength() <= 1.0) + { + return Candidate * Radius; + } + } + } + + /** + * Gets the current seed. + * + * @return Current seed. + */ + uint32 GetCurrentSeed() const + { + return Base2Seed; + } + + + FString ToString() const + { + return FString::Printf(TEXT("FStevesBalancedRandomStream2D(InitialSeed=%u, Seed=%u)"), InitialSeed, Base2Seed); + } +}; + +/// "Balanced" random 3D stream, using the Halton Sequence. Optimised for 3D only, more efficient than FStevesBalancedRandomStream +/// This is deterministic and more uniform in appearance than a general random stream (although not perfectly uniform) +USTRUCT(BlueprintType) +struct STEVESUEHELPERS_API FStevesBalancedRandomStream3D +{ + GENERATED_BODY() + +protected: + uint32 InitialSeed = 0; + uint32 Base2Seed = 0; + uint32 Base3Seed = 0; + uint32 Base5Seed = 0; + + FVector3f CurrentValue = FVector3f::ZeroVector; +public: + + FStevesBalancedRandomStream3D() + { + Initialize(0); + } + + /** + * Creates and initializes a new random stream from the specified seed value. + * + * @param InSeed The seed value. + */ + FStevesBalancedRandomStream3D( uint32 InSeed ) + { + Initialize(InSeed); + } + + /** + * Creates and initializes a new random stream from the specified name. + * + * @note If NAME_None is provided, the stream will be seeded using the current time. + * @param InName The name value from which the stream will be initialized. + */ + FStevesBalancedRandomStream3D( FName InName ) + { + Initialize(InName); + } + + /** + * Initializes this random stream with the specified seed value. + * + * @param InSeed The seed value. + */ + void Initialize( uint32 InSeed ) + { + while (InSeed > StevesRandConstants::kSafeMaxSeed3D) + { + InSeed -= StevesRandConstants::kSafeMaxSeed3D; + } + + InitialSeed = Base2Seed = InSeed; + + CurrentValue.X = Halton(Base2Seed, 2); + + CurrentValue.Y = 0.0f; + Base3Seed = 0; + + float p = StevesRandConstants::kOneOverThree; + + for (int i = 0, k = Base2Seed; k; i += 2, k /= 3) + { + int d = (k % 3); + Base3Seed |= d << i; + CurrentValue.Y += d * p; + p *= StevesRandConstants::kOneOverThree; + } + + CurrentValue.Z = 0.0f; + Base5Seed = 0; + + p = StevesRandConstants::kOneOverFive; + + for (int i = 0, k = Base2Seed; k; i += 3, k /= 5) + { + int d = (k % 5); + Base5Seed |= d << i; + CurrentValue.Z += d * p; + p *= StevesRandConstants::kOneOverFive; + } + } + + /** + * Initializes this random stream using the specified name. + * + * @note If NAME_None is provided, the stream will be seeded using the current time. + * @param InName The name value from which the stream will be initialized. + */ + void Initialize( FName InName ) + { + uint32 StartSeed; + if (InName != NAME_None) + { + StartSeed = GetTypeHash(InName.ToString()); + } + else + { + StartSeed = FPlatformTime::Cycles(); + } + Initialize(StartSeed); + } + + /** + * Resets this random stream to the initial seed value. + */ + void Reset() + { + Initialize(InitialSeed); + } + + uint32 GetInitialSeed() const + { + return Base2Seed; + } + + /** + * Generates a new random seed. + */ + void GenerateNewSeed() + { + Initialize(FMath::Rand()); + } + + /// Return a 3D value with each element between 0..1, inclusive + FVector Rand3D() + { + if (Base2Seed >= StevesRandConstants::kSafeMaxSeed3D) + { + Initialize(0); + } + + // This uses Andrew Wilmott's approach of calculating the next value at the same time as incrementing + // We calculate the new value while initialising / incrementing, so it's currently correct + const FVector3f Ret = CurrentValue; + + // base 2: 1 bit per digit + uint32_t OldBase2 = Base2Seed; + Base2Seed++; + uint32_t Diff = Base2Seed ^ OldBase2; + + // bottom bit always changes, higher bits + // change less frequently. + float s = 0.5f; + + // diff will be of the form 0 * 1 + , i.e. one bits up until the last carry. + // expected iterations = 1 + 0.5 + 0.25 + ... = 2 + do + { + if (OldBase2 & 1) + CurrentValue.X -= s; + else + CurrentValue.X += s; + + s *= 0.5f; + + Diff = Diff >> 1; + OldBase2 = OldBase2 >> 1; + } + while (Diff); + + + // base 3: use 2 bits for each base 3 digit. + uint32_t Mask = 0x3; // also the max base 3 digit + uint32_t Add = 0x1; // amount to add to force carry once digit==3 + s = StevesRandConstants::kOneOverThree; + + Base3Seed++; + + // expected iterations: 1.5 + while (true) + { + if ((Base3Seed & Mask) == Mask) + { + Base3Seed += Add; // force carry into next 2-bit digit + CurrentValue.Y -= 2 * s; + + Mask = Mask << 2; + Add = Add << 2; + + s *= StevesRandConstants::kOneOverThree; + } + else + { + CurrentValue.Y += s; // we know digit n has gone from a to a + 1 + break; + } + }; + + // base 5: use 3 bits for each base 5 digit. + Mask = 0x7; + Add = 0x3; // amount to add to force carry once digit==dmax + uint32_t Dmax = 0x5; // max digit + + s = StevesRandConstants::kOneOverFive; + + Base5Seed++; + + // expected iterations: 1.25 + while (true) + { + if ((Base5Seed & Mask) == Dmax) + { + Base5Seed += Add; // force carry into next 3-bit digit + CurrentValue.Z -= 4 * s; + + Mask = Mask << 3; + Dmax = Dmax << 3; + Add = Add << 3; + + s *= StevesRandConstants::kOneOverFive; + } + else + { + CurrentValue.Z += s; // we know digit n has gone from a to a + 1 + break; + } + }; + + return FVector(Ret.X, Ret.Y, Ret.Z); + } + + + /// Random point in a 3D box + FORCEINLINE FVector RandPointInBox(const FBox& Box) + { + const FVector R3 = Rand3D(); + return FVector(FMath::Lerp(Box.Min.X, Box.Max.X, R3.X), + FMath::Lerp(Box.Min.Y, Box.Max.Y, R3.Y), + FMath::Lerp(Box.Min.Z, Box.Max.Z, R3.Z)); + } + + + /// Random point in a sphere + FORCEINLINE FVector RandPointInSphere(float Radius = 1.0) + { + // Just use rejection sampling for simplicity / speed + while (true) + { + const FVector Candidate = Rand3D(); + if (Candidate.SquaredLength() <= 1.0) + { + return Candidate * Radius; + } + } + } + + /** + * Gets the current seed. + * + * @return Current seed. + */ + uint32 GetCurrentSeed() const + { + return InitialSeed; + } + + + FString ToString() const + { + return FString::Printf(TEXT("FStevesBalancedRandomStream(InitialSeed=%u, Seed=%u)"), InitialSeed, Base2Seed); + } + + +}; diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesDebugRenderSceneProxy.h b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesDebugRenderSceneProxy.h new file mode 100644 index 00000000..89e255d7 --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesDebugRenderSceneProxy.h @@ -0,0 +1,122 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license + +#pragma once + +#include "CoreMinimal.h" +#include "DebugRenderSceneProxy.h" + +/** + * An extension to FDebugRenderSceneProxy to support other shapes, e.g. circles and arcs + */ +class FStevesDebugRenderSceneProxy : public FDebugRenderSceneProxy +{ +public: + FStevesDebugRenderSceneProxy(const UPrimitiveComponent* InComponent) + : FDebugRenderSceneProxy(InComponent) + { + } + + STEVESUEHELPERS_API virtual void GetDynamicMeshElements(const TArray& Views, const FSceneViewFamily& ViewFamily, + uint32 VisibilityMap, FMeshElementCollector& Collector) const override; + + STEVESUEHELPERS_API virtual FPrimitiveViewRelevance GetViewRelevance(const FSceneView* View) const override; + + struct FDebugCircle + { + FDebugCircle(const FVector& InCentre, const FVector& InX, const FVector& InY, float InRadius, int InNumSegments, + const FColor& InColor, float InThickness = 0) : + Centre(InCentre), + X(InX), + Y(InY), + Radius(InRadius), + NumSegments(InNumSegments), + Color(InColor), + Thickness(InThickness) + { + } + + FVector Centre; + FVector X; + FVector Y; + float Radius; + int NumSegments; + FColor Color; + float Thickness; + }; + + /// An arc which is a section of a circle + struct FDebugArc + { + FDebugArc(const FVector& InCentre, const FVector& InX, const FVector& InY, float InMinAngle, float InMaxAngle, + float InRadius, int InNumSegments, const FColor& InColor) : + Centre(InCentre), + X(InX), + Y(InY), + MinAngle(InMinAngle), + MaxAngle(InMaxAngle), + Radius(InRadius), + NumSegments(InNumSegments), + Color(InColor) + { + } + + FVector Centre; + FVector X; + FVector Y; + float MinAngle; + float MaxAngle; + float Radius; + int NumSegments; + FColor Color; + }; + + /// Replacement for FWireCylinder because that's garbage and doesn't reflect the component transform + struct FDebugCylinder + { + FDebugCylinder(const FVector &InCentre, const FVector& InX, const FVector& InY, const FVector& InZ, const float InRadius, const float InHalfHeight, int InNumSegments, const FColor &InColor) : + Centre(InCentre), + X(InX), Y(InY), Z(InZ), + Radius(InRadius), + HalfHeight(InHalfHeight), + NumSegments(InNumSegments), + Color(InColor) + { + } + + FVector Centre; + FVector X; + FVector Y; + FVector Z; + float Radius; + float HalfHeight; + int NumSegments; + FColor Color; + }; + + struct FDebugMesh + { + FDebugMesh(const FMatrix& InLocalToWorld, + const TArray& InVertices, + const TArray& InIndices, + const FColor& InColor) + : LocalToWorld(InLocalToWorld), + Vertices(InVertices), + Indices(InIndices), + Color(InColor) + { + } + + FMatrix LocalToWorld; + TArray Vertices; + TArray Indices; + FColor Color; + }; + + TArray Circles; + TArray Arcs; + TArray CylindersImproved; // Because we need our own + TArray CapsulesImproved; // Because we need our own + TArray MeshesImproved; // Because we need our own + +}; diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesDynamicNavMeshVolume.h b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesDynamicNavMeshVolume.h new file mode 100644 index 00000000..cf9ea6cb --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesDynamicNavMeshVolume.h @@ -0,0 +1,27 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license + +#pragma once + +#include "CoreMinimal.h" +#include "NavMesh/NavMeshBoundsVolume.h" +#include "StevesDynamicNavMeshVolume.generated.h" + +/// NavMesh bounds that can be changed at runtime (requires that your NavMesh data Runtime Generation is set to Dynamic) +UCLASS() +class STEVESUEHELPERS_API AStevesDynamicNavMeshVolume : public ANavMeshBoundsVolume +{ + GENERATED_BODY() + +public: + AStevesDynamicNavMeshVolume(); + + UFUNCTION(BlueprintCallable, Category="DynamicNavMesh") + void SetLocationAndDimensions(const FVector& Location, const FVector& NewDimensions); + UFUNCTION(BlueprintCallable, Category="DynamicNavMesh") + void SetDimensions(const FVector& NewDimensions); + +protected: + void UpdateDimensions(const FVector& NewDimensions); + void NotifyNavSystem(); +}; diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesEasings.h b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesEasings.h new file mode 100644 index 00000000..429644ee --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesEasings.h @@ -0,0 +1,138 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +#pragma once + +#include "CoreMinimal.h" +#include "Kismet/BlueprintFunctionLibrary.h" + +#include "StevesEasings.generated.h" + +/// Easing functions +/// See https://easings.net/ +/// Could have used UE EEasingFunc but it's missing some nice options like back/elastic +UENUM(BlueprintType) +enum class EStevesEaseFunction : uint8 +{ + Linear, + EaseIn_Sine, + EaseOut_Sine, + EaseInOut_Sine, + EaseIn_Quad, + EaseOut_Quad, + EaseInOut_Quad, + EaseIn_Cubic, + EaseOut_Cubic, + EaseInOut_Cubic, + EaseIn_Quart, + EaseOut_Quart, + EaseInOut_Quart, + EaseIn_Quint, + EaseOut_Quint, + EaseInOut_Quint, + EaseIn_Expo, + EaseOut_Expo, + EaseInOut_Expo, + EaseIn_Circ, + EaseOut_Circ, + EaseInOut_Circ, + EaseIn_Back, + EaseOut_Back, + EaseInOut_Back, + EaseIn_Elastic, + EaseOut_Elastic, + EaseInOut_Elastic, + EaseIn_Bounce, + EaseOut_Bounce, + EaseInOut_Bounce +}; + +UCLASS() +class STEVESUEHELPERS_API UStevesEasings : public UBlueprintFunctionLibrary +{ + GENERATED_BODY() + +public: + + /** + * Convert a linear alpha value into an eased alpha value using an easing function + * @param InAlpha The input linear alpha + * @param Func The easing function + * @return The eased version of the alpha + */ + UFUNCTION(BlueprintCallable, Category="StevesEaseMath") + static float EaseAlpha(float InAlpha, EStevesEaseFunction Func); + + /** + * Interpolate with easing function support + * @param A Input Value from + * @param B Input Value to + * @param Alpha Value between 0 and 1 + * @param Func Easing function + * @return Interpolated value + */ + UFUNCTION(BlueprintCallable, Category="StevesEaseMath") + static float EaseFloat(float A, float B, float Alpha, EStevesEaseFunction Func) + { + return A + EaseAlpha(Alpha, Func) * (B - A); + } + /** + * Interpolate with easing function support + * @param A Input Value from + * @param B Input Value to + * @param Alpha Value between 0 and 1 + * @param Func Easing function + * @return Interpolated value + */ + UFUNCTION(BlueprintCallable, Category="StevesEaseMath") + static FVector EaseVector(const FVector& A, const FVector& B, float Alpha, EStevesEaseFunction Func) + { + return A + EaseAlpha(Alpha, Func) * (B - A); + } + /** + * Interpolate with easing function support + * @param A Input Value from + * @param B Input Value to + * @param Alpha Value between 0 and 1 + * @param Func Easing function + * @return Interpolated value + */ + UFUNCTION(BlueprintCallable, Category="StevesEaseMath") + static FRotator EaseRotator(const FRotator& A, const FRotator& B, float Alpha, EStevesEaseFunction Func, bool bShortest) + { + if (bShortest) + return EaseQuat(FQuat(A), FQuat(B), Alpha, Func).Rotator(); + + return A + EaseAlpha(Alpha, Func) * (B - A); + } + /** + * Interpolate with easing function support + * @param A Input Value from + * @param B Input Value to + * @param Alpha Value between 0 and 1 + * @param Func Easing function + * @return Interpolated value + */ + UFUNCTION(BlueprintCallable, Category="StevesEaseMath") + static FQuat EaseQuat(const FQuat& A, const FQuat& B, float Alpha, EStevesEaseFunction Func) + { + return FQuat::Slerp(A, B, EaseAlpha(Alpha, Func)); + } + /** + * Interpolate with easing function support + * @param A Input Value from + * @param B Input Value to + * @param Alpha Value between 0 and 1 + * @param Func Easing function + * @return Interpolated value + */ + UFUNCTION(BlueprintCallable, Category="StevesEaseMath") + static FTransform EaseTransform(const FTransform& A, const FTransform& B, float Alpha, EStevesEaseFunction Func) + { + return FTransform( + EaseQuat(A.GetRotation(), B.GetRotation(), Alpha, Func), + EaseVector(A.GetLocation(), B.GetLocation(), Alpha, Func), + EaseVector(A.GetScale3D(), B.GetScale3D(), Alpha, Func)); + } + + +}; diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesEditorVisComponent.h b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesEditorVisComponent.h new file mode 100644 index 00000000..c49ef2c1 --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesEditorVisComponent.h @@ -0,0 +1,348 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license + +#pragma once + +#include "CoreMinimal.h" +#include "Components/PrimitiveComponent.h" +#include "StevesEditorVisComponent.generated.h" + + +USTRUCT(BlueprintType) +struct STEVESUEHELPERS_API FStevesEditorVisLine +{ + GENERATED_BODY() + + /// Start location relative to component + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="StevesVisLine") + FVector Start; + /// End location relative to component + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="StevesVisLine") + FVector End; + /// The colour of the line render + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="StevesVisLine") + FColor Colour; + + FStevesEditorVisLine(const FVector& InStart, const FVector& InEnd, + const FColor& InColour) + : Start(InStart), + End(InEnd), + Colour(InColour) + { + } + + FStevesEditorVisLine(): + Start(FVector::ZeroVector), + End(FVector(100, 0, 0)), + Colour(FColor::White) + { + } +}; + +USTRUCT(BlueprintType) +struct STEVESUEHELPERS_API FStevesEditorVisCircle +{ + GENERATED_BODY() + + /// Location relative to component + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="StevesVisCircle") + FVector Location; + /// Rotation relative to component; circles will be rendered in the X/Y plane + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="StevesVisCircle") + FRotator Rotation; + /// Circle radius + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="StevesVisCircle") + float Radius; + /// The number of line segments to render the circle with + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="StevesVisCircle") + int NumSegments; + /// The colour of the line render + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="StevesVisCircle") + FColor Colour; + + FStevesEditorVisCircle(const FVector& InLocation, const FRotator& InRotation, float InRadius, int InNumSegments, + const FColor& InColour) + : Location(InLocation), + Rotation(InRotation), + Radius(InRadius), + NumSegments(InNumSegments), + Colour(InColour) + { + } + + FStevesEditorVisCircle(): + Location(FVector::ZeroVector), + Rotation(FRotator::ZeroRotator), + Radius(50), NumSegments(12), + Colour(FColor::White) + { + } +}; + +USTRUCT(BlueprintType) +struct STEVESUEHELPERS_API FStevesEditorVisArc +{ + GENERATED_BODY() + + /// Location relative to component + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="StevesVisArc") + FVector Location; + /// Rotation relative to component; arcs will be rendered in the X/Y plane + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="StevesVisArc") + FRotator Rotation; + /// Minimum angle to render arc from + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="StevesVisArc") + float MinAngle; + /// Maximum angle to render arc to + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="StevesVisArc") + float MaxAngle; + /// Circle radius + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="StevesVisArc") + float Radius; + /// The number of line segments to render the circle with + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="StevesVisArc") + int NumSegments; + /// The colour of the line render + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="StevesVisArc") + FColor Colour; + + FStevesEditorVisArc(const FVector& InLocation, const FRotator& InRotation, float InMinAngle, float InMaxAngle, + float InRadius, int InNumSegments, + const FColor& InColour) + : Location(InLocation), + Rotation(InRotation), + MinAngle(InMinAngle), + MaxAngle(InMaxAngle), + Radius(InRadius), + NumSegments(InNumSegments), + Colour(InColour) + { + } + + FStevesEditorVisArc(): + Location(FVector::ZeroVector), + Rotation(FRotator::ZeroRotator), + MinAngle(0), + MaxAngle(180), + Radius(50), NumSegments(12), + Colour(FColor::White) + { + } +}; + +USTRUCT(BlueprintType) +struct STEVESUEHELPERS_API FStevesEditorVisSphere +{ + GENERATED_BODY() + + /// Location relative to component + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="StevesVisSphere") + FVector Location; + /// Sphere radius + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="StevesVisSphere") + float Radius; + /// The colour of the line render + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="StevesVisSphere") + FColor Colour; + + FStevesEditorVisSphere(const FVector& InLocation, float InRadius, const FColor& InColour) : + Location(InLocation), + Radius(InRadius), + Colour(InColour) + { + } + + FStevesEditorVisSphere(): + Location(FVector::ZeroVector), + Radius(50), + Colour(FColor::White) + { + } +}; + +USTRUCT(BlueprintType) +struct STEVESUEHELPERS_API FStevesEditorVisBox +{ + GENERATED_BODY() + + /// Location relative to component + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="StevesVisBox") + FVector Location; + /// Size of box in each axis + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="StevesVisBox") + FVector Size; + /// Rotation relative to component + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="StevesVisBox") + FRotator Rotation; + /// The colour of the line render + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="StevesVisBox") + FColor Colour; + + FStevesEditorVisBox(const FVector& InLocation, const FVector& InSize, const FRotator& InRot, + const FColor& InColour) : + Location(InLocation), + Size(InSize), + Rotation(InRot), + Colour(InColour) + { + } + + FStevesEditorVisBox(): + Location(FVector::ZeroVector), + Size(FVector(50, 50, 50)), + Rotation(FRotator::ZeroRotator), + Colour(FColor::White) + { + } +}; + +USTRUCT(BlueprintType) +struct STEVESUEHELPERS_API FStevesEditorVisCylinder +{ + GENERATED_BODY() + + /// Location relative to component + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="StevesVisCylinder") + FVector Location; + /// Height of cylinder + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="StevesVisCylinder") + float Height; + /// Radius of cylinder + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="StevesVisCylinder") + float Radius; + /// Rotation relative to component + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="StevesVisCylinder") + FRotator Rotation; + /// The colour of the line render + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="StevesVisCylinder") + FColor Colour; + + FStevesEditorVisCylinder(const FVector& InLocation, float InHeight, float InRadius, const FRotator& InRot, + const FColor& InColour) : + Location(InLocation), + Height(InHeight), + Radius(InRadius), + Rotation(InRot), + Colour(InColour) + { + } + + FStevesEditorVisCylinder(): + Location(FVector::ZeroVector), + Height(50), + Radius(10), + Rotation(FRotator::ZeroRotator), + Colour(FColor::White) + { + } +}; + +USTRUCT(BlueprintType) +struct STEVESUEHELPERS_API FStevesEditorVisCapsule : public FStevesEditorVisCylinder +{ + GENERATED_BODY() + + FStevesEditorVisCapsule(const FVector& InLocation, float InHeight, float InRadius, const FRotator& InRot, + const FColor& InColour) : FStevesEditorVisCylinder(InLocation, InHeight, InRadius, InRot, InColour) + { + } + + FStevesEditorVisCapsule() : FStevesEditorVisCylinder() + { + } + +}; + +USTRUCT(BlueprintType) +struct STEVESUEHELPERS_API FStevesEditorVisMesh +{ + GENERATED_BODY() + + /// The mesh do display + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="StevesVisMesh") + TObjectPtr Mesh; + /// Location relative to component + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="StevesVisMesh") + FVector Location; + /// Scale of the mesh compared to component scale + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="StevesVisMesh") + FVector Scale; + /// Rotation relative to component + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="StevesVisMesh") + FRotator Rotation; + /// The colour of the line render + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="StevesVisMesh") + FColor Colour; + /// Whether to use the lowest detail LOD for vis + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="StevesVisMesh") + bool bUseLowestLOD = true; + + FStevesEditorVisMesh(UStaticMesh* InMesh, + const FVector& InLocation, + const FVector& InScale, + const FRotator& InRot, + const FColor& InColour, + bool InUseLowestLOD = true) : + Mesh(InMesh), + Location(InLocation), + Scale(InScale), + Rotation(InRot), + Colour(InColour), + bUseLowestLOD(InUseLowestLOD) + { + } + + FStevesEditorVisMesh() : Mesh(nullptr), + Location(FVector::ZeroVector), + Scale(FVector::OneVector), + Rotation(FRotator::ZeroRotator), + Colour(FColor::White) + { + } +}; + +/** + * + * A component that solely exists to provide arbitrary editor visualisation when not selected. + * FComponentVisualizer can only display visualisation when selected. + * To display vis on an unselected object, you need a UPrimitiveComponent, and sometimes you don't want/need one of those + * in your actor. Instead, add UStevesEditorVisComponent at construction of your actor, or registration of another component, + * but only in a WITH_EDITOR block. Then, get nice visualisation of your actor/component without making more invasive changes + * to your code. + * + * If you want to, you can add this to your Blueprints too. This component automatically marks itself as "visualisation + * only" so shouldn't have a runtime impact. + */ +UCLASS(Blueprintable, ClassGroup="Utility", hidecategories=(Collision,Physics,Object,LOD,Lighting,TextureStreaming), + meta=(DisplayName="Steves Editor Visualisation", BlueprintSpawnableComponent)) +class STEVESUEHELPERS_API UStevesEditorVisComponent : public UPrimitiveComponent +{ + GENERATED_BODY() +public: + /// Circles to render + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="StevesVisComponent") + TArray Lines; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="StevesVisComponent") + TArray Arrows; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="StevesVisComponent") + TArray Circles; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="StevesVisComponent") + TArray Arcs; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="StevesVisComponent") + TArray Spheres; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="StevesVisComponent") + TArray Boxes; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="StevesVisComponent") + TArray Cylinders; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="StevesVisComponent") + TArray Capsules; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="StevesVisComponent") + TArray Meshes; + + UStevesEditorVisComponent(const FObjectInitializer& ObjectInitializer); + + virtual FPrimitiveSceneProxy* CreateSceneProxy() override; + virtual FBoxSphereBounds CalcBounds(const FTransform& LocalToWorld) const override; + /// Needs to update on transform since proxy is detached + virtual bool ShouldRecreateProxyOnUpdateTransform() const override { return true; } +}; diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesFixedDataTableRowHandle.h b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesFixedDataTableRowHandle.h new file mode 100644 index 00000000..5f1f3c25 --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesFixedDataTableRowHandle.h @@ -0,0 +1,19 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +#pragma once +#include "Engine/DataTable.h" + +#include "StevesFixedDataTableRowHandle.generated.h" + +/// Just a type to denote that this table row handle should be edited differently +/// +/// Usage: +/// UPROPERTY(EditAnywhere, meta=(DataTable="/Game/Data/DT_YourData")) +/// FStevesFixedDataTableRowHandle Row; +/// +USTRUCT(BlueprintType) +struct STEVESUEHELPERS_API FStevesFixedDataTableRowHandle : public FDataTableRowHandle +{ + GENERATED_USTRUCT_BODY() +}; + \ No newline at end of file diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesGameSubsystem.h b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesGameSubsystem.h new file mode 100644 index 00000000..5ba82457 --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesGameSubsystem.h @@ -0,0 +1,372 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +#pragma once + +#include "CoreMinimal.h" +#include "Subsystems/GameInstanceSubsystem.h" +#include "InputCoreTypes.h" +#include "PaperSprite.h" +#include "Framework/Application/IInputProcessor.h" +#include "StevesHelperCommon.h" +#include "StevesTextureRenderTargetPool.h" +#include "StevesUI/FocusSystem.h" +#include "StevesUI/InputImage.h" +#include "StevesUI/UiTheme.h" +#include "Templates/TypeHash.h" + +#include "StevesGameSubsystem.generated.h" + +DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnInputModeChanged, int, PlayerIndex, EInputMode, InputMode); +DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnEnhancedInputMappingsChanged); +DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnWindowForegroundChanged, bool, bFocussed); +DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnEnhancedInputActionTriggered, const UInputAction*, Action, ETriggerEvent, TriggeredEvent); +DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnViewportResized, int, XSize, int, YSize); + +/// Entry point for all the top-level features of the helper system +UCLASS(Config=Game) +class STEVESUEHELPERS_API UStevesGameSubsystem : public UGameInstanceSubsystem +{ + GENERATED_BODY() + +protected: + /// The default UiTheme path, the theme to use if controls don't specifically link to one. + /// Customise this in DefaultGame.ini + /// [/Script/StevesUEHelpers.StevesGameSubsystem] + /// DefaultUiThemePath="/Game/Some/Other/UiTheme.UiTheme" + /// Regardless, remember to register this file as a Primary Asset in Project Settings, + /// as described in https://docs.unrealengine.com/en-US/Engine/Basics/AssetsAndPackages/AssetManagement/index.html + /// so that it's included when packaging + UPROPERTY(Config) + FString DefaultUiThemePath; + + struct FEnhancedInputInterest + { + + TWeakObjectPtr Action; + ETriggerEvent Trigger; + FEnhancedInputInterest(const UInputAction* TheAction, ETriggerEvent TheTriggerEvent) : Action(TheAction), Trigger(TheTriggerEvent) {} + + + friend uint32 GetTypeHash(const FEnhancedInputInterest& Arg) + { + return HashCombine(GetTypeHash(Arg.Action), GetTypeHash(Arg.Trigger)); + } + + friend bool operator==(const FEnhancedInputInterest& Lhs, const FEnhancedInputInterest& RHS) + { + return Lhs.Action == RHS.Action + && Lhs.Trigger == RHS.Trigger; + } + + friend bool operator!=(const FEnhancedInputInterest& Lhs, const FEnhancedInputInterest& RHS) + { + return !(Lhs == RHS); + } + }; + + TSet RegisteredEnhancedInputActionInterests; + +public: + virtual void Initialize(FSubsystemCollectionBase& Collection) override; + virtual void Deinitialize() override; + +protected: + DECLARE_DELEGATE_TwoParams(FInternalInputModeChanged, int /* PlayerIndex */, EInputMode) + /** + * We seem to need a separate non-UObject for an IInputProcessor, my attempt to combine the 2 fails. + * So this class just acts as a safe relay between Slate and UInputHelper + * + * This class should be registered as an input processor in order to capture all input events & detect + * what kind of devices are being used. We can't use PlayerController to do this reliably because in UMG + * mode, all the mouse move events are consumed by Slate and you never see them, so it's not possible to + * detect when the user moved a mouse. + * + * This class should be instantiated and used from some UObject of your choice, e.g. your GameInstance class, + * something like this: + * + * InputDetector = MakeShareable(new FInputModeDetector()); + * FSlateApplication::Get().RegisterInputPreProcessor(InputDetector); + * InputDetector->OnInputModeChanged.BindUObject(this, &UMyGameInstance::OnInputDetectorModeChanged); + * + * Note how the OnInputModeChanged on this object is a simple delegate, not a dynamic multicast etc, because + * this is not a UObject. You should relay the input mode event changed through the owner if you want to distribute + * the information further. + */ + class FInputModeDetector : public IInputProcessor, public TSharedFromThis + { + protected: + TArray LastInputModeByPlayer; + TArray LastButtonPressByPlayer; + TArray LastAxisMoveByPlayer; + + const EInputMode DefaultInputMode = EInputMode::Mouse; + const EInputMode DefaultButtonInputMode = EInputMode::Keyboard; + const EInputMode DefaultAxisInputMode = EInputMode::Mouse; + const float MouseMoveThreshold = 1; + const float GamepadAxisThreshold = 0.2f; + + bool ShouldProcessInputEvents() const; + public: + /// Whether this detector should ignore events (e.g. because the application is in the background) + bool bIgnoreEvents = false; + + bool bIgnoreNextMouseMove = false; + + /// Event raised when main input mode changes for any reason + FInternalInputModeChanged OnInputModeChanged; + /// Event raised when button input mode changes only + FInternalInputModeChanged OnButtonInputModeChanged; + /// Event raised when axis input mode changes only + FInternalInputModeChanged OnAxisInputModeChanged; + + + FInputModeDetector(); + + void IgnoreNextMouseMove() { bIgnoreNextMouseMove = true;} + + virtual bool HandleKeyDownEvent(FSlateApplication& SlateApp, const FKeyEvent& InKeyEvent) override; + virtual bool + HandleAnalogInputEvent(FSlateApplication& SlateApp, const FAnalogInputEvent& InAnalogInputEvent) override; + virtual bool HandleMouseMoveEvent(FSlateApplication& SlateApp, const FPointerEvent& MouseEvent) override; + virtual bool HandleMouseButtonDownEvent(FSlateApplication& SlateApp, const FPointerEvent& MouseEvent) override; + virtual bool HandleMouseWheelOrGestureEvent(FSlateApplication& SlateApp, const FPointerEvent& InWheelEvent, + const FPointerEvent* InGestureEvent) override; + + /// Get the last input mode from any kind of input + EInputMode GetLastInputMode(int PlayerIndex = 0); + /// Get the last input mode from button inputs (ignores axis changes, good for detecting if keyboard or mouse buttons are being used) + EInputMode GetLastButtonInputMode(int PlayerIndex = 0); + /// Get the last input mode from axis movement (ignores button presses, good for detecting if keyboard or mouse axes are being used for motion) + EInputMode GetLastAxisInputMode(int PlayerIndex = 0); + + // Needed but unused + virtual void Tick(const float DeltaTime, FSlateApplication& SlateApp, TSharedRef Cursor) override {} + + protected: + static bool IsAGamepadButton(const FKey& Key); + void ProcessKeyOrButton(int PlayerIndex, FKey Key); + void SetMode(int PlayerIndex, EInputMode NewMode, bool bIsButton); + }; + +protected: + TSharedPtr InputDetector; + FFocusSystem FocusSystem; + bool bCheckedViewportClient = false; + + FTimerHandle ForegroundCheckHandle; + + UPROPERTY(BlueprintReadOnly, Category="StevesGameSubsystem") + bool bIsForeground = true; + + UPROPERTY(BlueprintReadWrite, Category="StevesGameSubsystem") + TObjectPtr DefaultUiTheme; + + TArray TextureRenderTargetPools; + + void CreateInputDetector(); + void DestroyInputDetector(); + void InitTheme(); + void InitForegroundCheck(); + void CheckForeground(); + void InitViewport(); + void ViewportResized(FViewport* Viewport, unsigned Unused); + void FullscreenToggled(bool bFullscreen); + + + // Called by detector + void OnInputDetectorModeChanged(int PlayerIndex, EInputMode NewMode); + void OnButtonInputDetectorModeChanged(int PlayerIndex, EInputMode NewMode); + void OnAxisInputDetectorModeChanged(int PlayerIndex, EInputMode NewMode); + + + TSoftObjectPtr GetGamepadImages(int PlayerIndex, const UUiTheme* Theme); + UPaperSprite* GetImageSpriteFromTable(const FKey& Key, const TSoftObjectPtr& Asset); + UFUNCTION() + void EnhancedInputActionTriggered(const FInputActionInstance& InputActionInstance); + +public: + + /// Event raised when main input mode changed between gamepad and keyboard / mouse (for any of axis / button events) + UPROPERTY(BlueprintAssignable) + FOnInputModeChanged OnInputModeChanged; + + /// Event raised when the last button input changed between gamepad / keyboard / mouse + /// This can happen at a different time to OnInputModeChanged, e.g. if that was triggered by a mouse move, but the + /// last button pressed was still keyboard, you'd get this event later + UPROPERTY(BlueprintAssignable) + FOnInputModeChanged OnButtonInputModeChanged; + + /// Event raised when the last axis input changed between gamepad / keyboard / mouse + /// This can happen at a different time to OnInputModeChanged, e.g. if that was triggered by a button press, but the + /// last axis moved was still mouse, you'd get this event later + UPROPERTY(BlueprintAssignable) + FOnInputModeChanged OnAxisInputModeChanged; + + /// DEPRECATED: Event raised just after the Enhanced Input mappings have changed + /// User-triggered via NotifyEnhancedInputMappingsChanged + UPROPERTY(BlueprintAssignable) + FOnEnhancedInputMappingsChanged OnEnhancedInputMappingsChanged; + + /// Event fired when an enhanced input event that an interest has previously been registered in triggers. + /// Nothing will fire on this event unless you call RegisterInterestInEnhancedInputAction to listen for it. + UPROPERTY(BlueprintAssignable) + FOnEnhancedInputActionTriggered OnEnhancedInputActionTriggered; + + /// Event raised when the game window's foreground status changes + UPROPERTY(BlueprintAssignable) + FOnWindowForegroundChanged OnWindowForegroundChanged; + + /// Event raised when the viewport changes size + UPROPERTY(BlueprintAssignable) + FOnViewportResized OnViewportResized; + + /// Gets the device where the most recent input event of any kind happened + UFUNCTION(BlueprintCallable, Category="StevesGameSubsystem") + EInputMode GetLastInputModeUsed(int PlayerIndex = 0) const { return InputDetector->GetLastInputMode(PlayerIndex); } + /// Gets the device where the most recent button press happened + UFUNCTION(BlueprintCallable, Category="StevesGameSubsystem") + EInputMode GetLastInputButtonPressed(int PlayerIndex = 0) const { return InputDetector->GetLastButtonInputMode(PlayerIndex); } + /// Gets the device where the most recent axis move happened + UFUNCTION(BlueprintCallable, Category="StevesGameSubsystem") + EInputMode GetLastInputAxisMoved(int PlayerIndex = 0) const { return InputDetector->GetLastAxisInputMode(PlayerIndex); } + + UFUNCTION(BlueprintCallable, Category="StevesGameSubsystem") + bool LastInputWasGamePad(int PlayerIndex = 0) const { return GetLastInputModeUsed(PlayerIndex) == EInputMode::Gamepad; } + + /// Gets the default UI theme object (defaults to our own) + /// You can override this if you want + UUiTheme* GetDefaultUiTheme() { return DefaultUiTheme; }; + + /// Changes the default theme to a different one + void SetDefaultUiTheme(UUiTheme* NewTheme) { DefaultUiTheme = NewTheme; } + + /// Get the global focus system + FFocusSystem* GetFocusSystem(); + + /// Return whether the game is currently in the foreground + bool IsForeground() const { return bIsForeground; } + + /** + * @brief Get an input button / key / axis image as a sprite based on any combination of action / axis binding or manual key + * @param BindingType The type of input binding to look up + * @param ActionOrAxis The name of the action or axis, if BindingType is looking for that + * @param Key The explicit key you want to display, if the BindingType is set to custom key + * @param DevicePreference The order of preference for images where multiple devices have mappings. In the case of multiple mappings for the same device, the first one will be used. + * @param PlayerIndex The player index to look up the binding for + * @param Theme Optional explicit theme, if blank use the default theme + * @return + */ + UFUNCTION(BlueprintCallable, Category="StevesGameSubsystem") + UPaperSprite* GetInputImageSprite(EInputBindingType BindingType, + FName ActionOrAxis, + FKey Key, + EInputImageDevicePreference DevicePreference, + int PlayerIndex = 0, + const UUiTheme* Theme = nullptr); + + /** + * @brief Get an input button / key / axis image as a sprite based on an enhanced input action + * @param Action The input action + * @param DevicePreference The order of preference for images where multiple devices have mappings. In the case of multiple mappings for the same device, the first one will be used. + * @param PlayerIdx The player index to look up the binding for + * @param PC The player controller to look up the binding for + * @param Theme Optional explicit theme, if blank use the default theme + * @return + */ + UPaperSprite* GetInputImageSpriteFromEnhancedInputAction(UInputAction* Action, + EInputImageDevicePreference DevicePreference, + int PlayerIdx, + APlayerController* PC, + UUiTheme* Theme = nullptr); + + /** + * @brief Get an input button / key image from an action + * @param Name The name of the action + * @param DevicePreference The order of preference for images where multiple devices have mappings. In the case of multiple mappings for the same device, the first one will be used. + * @param PlayerIndex The player index to look up the binding for + * @param Theme Optional explicit theme, if blank use the default theme + * @return + */ + UPaperSprite* GetInputImageSpriteFromAction(const FName& Name, + EInputImageDevicePreference DevicePreference = + EInputImageDevicePreference::Gamepad_Keyboard_Mouse, + int PlayerIndex = 0, + const UUiTheme* Theme = nullptr); + /** + * @brief Get an input image from an axis + * @param Name The name of the axis + * @param DevicePreference The order of preference for images where multiple devices have mappings. In the case of multiple mappings for the same device, the first one will be used. + * @param PlayerIndex The player index to look up the binding for + * @param Theme Optional explicit theme, if blank use the default theme + * @return + */ + UPaperSprite* GetInputImageSpriteFromAxis(const FName& Name, + EInputImageDevicePreference DevicePreference = + EInputImageDevicePreference::Gamepad_Mouse_Keyboard, + int PlayerIndex = 0, + const UUiTheme* Theme = nullptr); + + + + + /** + * @brief Get an input image for a specific key + * @param Key The key to look up + * @param Theme Optional explicit theme, if blank use the default theme + * @return + */ + UPaperSprite* GetInputImageSpriteFromKey(const FKey& Key, int PlayerIndex = 0, const UUiTheme* Theme = nullptr); + + /** + * @brief Set the content of a slate brush from an atlas (e.g. sprite) + * @param Brush The brush to update + * @param AtlasRegion Atlas to use as source e.g. a Sprite + * @param bMatchSize Whether to resize the brush to match the atlas entry + */ + static void SetBrushFromAtlas(FSlateBrush* Brush, TScriptInterface AtlasRegion, + bool bMatchSize); + + + + /** + * Retrieve a pool of texture render targets. If a pool doesn't exist with the given name, it can be created. + * @param Name Identifier for the pool. + * @param bAutoCreate + * @return The pool, or null if it doesn't exist and bAutoCreate is false + */ + FStevesTextureRenderTargetPoolPtr GetTextureRenderTargetPool(FName Name, bool bAutoCreate = true); + + /** + * DEPRECATED - no longer required + * Notify this subsystem that changes have been made to the Enhanced Input mappings, e.g. adding or removing a context. + * Unfortunately, the Enhanced Input plugin currently provides NO WAY for us to monitor context changes automatically, + * so we need the user to tell us when they make a change. + * This call is however slightly delayed before being acted upon, because EI defers the rebuild of mappings until the next tick. + */ + UFUNCTION(BlueprintCallable, Category="StevesGameSubsystem") + void NotifyEnhancedInputMappingsChanged(); + + /** Attempt to find an enhanced input action by name in the configured folders. + */ + TSoftObjectPtr FindEnhancedInputAction(const FString& Name); + + + /// Register an interest in an enhanced input action. Calling this will result in OnEnhancedInputActionTriggered being called + /// when this action is triggered. + /// This is mainly for use in UI bindings. You only need to call it once for each UI-specific action. + UFUNCTION(BlueprintCallable, Category="StevesGameSubsystem") + void RegisterInterestInEnhancedInputAction(const UInputAction* Action, ETriggerEvent TriggerEvent); + + // Unregister all previously registered interests in input actions. This can be needed if a new scene is loaded, as previously + // regsitered InputActions will no longer fire + UFUNCTION(BlueprintCallable, Category="StevesGameSubsystem") + void UnregisterAllInterestInEnhancedInputActions(); + + /// Moves the mouse pointer offscreen so that it can't trigger hovers on anything. This happens by + /// default when switching to gamepad mode, but in edge cases I've found it useful to ensure that + /// some other effect doesn't interfere with gamepad navigation by relocating the mouse pointer to + /// the centre (I've seen this rarely but can't fully explain it). By default all MenuStacks call this + /// on open if the current input is gamepad, to ensure the mouse is out of the way. + UFUNCTION(BlueprintCallable, Category="StevesGameSubsystem") + void MoveMouseOffScreen(bool bAlsoHide = true) const; +}; diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesGameViewportClientBase.h b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesGameViewportClientBase.h new file mode 100644 index 00000000..2426d1a0 --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesGameViewportClientBase.h @@ -0,0 +1,26 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +#pragma once + +#include "CoreMinimal.h" +#include "Engine/GameViewportClient.h" + +#include "StevesGameViewportClientBase.generated.h" + +UCLASS() +class STEVESUEHELPERS_API UStevesGameViewportClientBase : public UGameViewportClient +{ + GENERATED_BODY() + + protected: + bool bSuppressMouseCursor; + + public: + + + virtual void Init(FWorldContext& WorldContext, UGameInstance* OwningGameInstance, + bool bCreateNewAudioDevice) override; + virtual EMouseCursor::Type GetCursor(FViewport* Viewport, int32 X, int32 Y) override; + + virtual void SetSuppressMouseCursor(bool bSuppress); +}; diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesHelperCommon.h b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesHelperCommon.h new file mode 100644 index 00000000..968e6d22 --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesHelperCommon.h @@ -0,0 +1,71 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +#pragma once +#include "UObject/ObjectMacros.h" + +UENUM(BlueprintType) +enum class EInputMode : uint8 +{ + Mouse, + Keyboard, + Gamepad, + Unknown +}; + +UENUM(BlueprintType) +enum class EInputModeChange : uint8 +{ + DoNotChange UMETA(DisplayName="No Change"), + Restore UMETA(DisplayName="Restore To Previous"), + UIOnly UMETA(DisplayName="UI Only"), + GameAndUI UMETA(DisplayName="Game And UI"), + GameOnly UMETA(DisplayName="Game Only") +}; + +UENUM(BlueprintType) +enum class EMousePointerVisibilityChange : uint8 +{ + DoNotChange UMETA(DisplayName="No Change"), + Restore UMETA(DisplayName="Restore To Previous"), + Visible UMETA(DisplayName="Pointer Visible"), + Hidden UMETA(DisplayName="Pointer Hidden") +}; + +UENUM(BlueprintType) +enum class EGamePauseChange : uint8 +{ + DoNotChange UMETA(DisplayName="No Change"), + Restore UMETA(DisplayName="Restore To Previous"), + Paused UMETA(DisplayName="Pause Game"), + Unpaused UMETA(DisplayName="Unpause Game") +}; + +UENUM(BlueprintType) +enum class EInputBindingType : uint8 +{ + /// A legacy button action, will be looked up based on input mappings + Action = 0, + /// An legacy axis action, will be looked up based on input mappings + Axis = 1, + /// A manually specified FKey (which can be key, button, axis) + Key = 2, + /// An EnhancedInput action + EnhancedInputAction = 3 +}; + +/// What order of preference should we return input images where an action/axis has multiple mappings +UENUM(BlueprintType) +enum class EInputImageDevicePreference : uint8 +{ + /// For actions, use Gamepad_Keyboard_Mouse_Button, for axes, use Gamepad_Mouse_Keyboard + Auto, + /// Gamepad first, then keyboard, then mouse + Gamepad_Keyboard_Mouse UMETA(DisplayName="Gamepad, Keyboard, Mouse"), + /// Gamepad first, then mouse, then keyboard - this is usually best for axes + Gamepad_Mouse_Keyboard UMETA(DisplayName="Gamepad, Mouse, Keyboard"), + /// Gamepad first, then whichever of mouse or keyboard last had a BUTTON pressed (ignore axes) - this is usually best for actions (buttons) + Gamepad_Keyboard_Mouse_Button UMETA(DisplayName="Gamepad, Most Recent Button Keyboard/Mouse"), + /// Gamepad first, then whichever of mouse or keyboard last had an AXIS moved (ignore buttons) - this is usually best for directionals + Gamepad_Keyboard_Mouse_Axis UMETA(DisplayName="Gamepad, Most Recent Axis Keyboard/Mouse") +}; + diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesLightFlicker.h b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesLightFlicker.h new file mode 100644 index 00000000..f0ba5a99 --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesLightFlicker.h @@ -0,0 +1,134 @@ +// Copyright Steve Streeting +// Licensed under the MIT License (see License.txt) + +#pragma once + +#include "CoreMinimal.h" +#include "Kismet/BlueprintFunctionLibrary.h" +#include "Curves/RichCurve.h" +#include "Components/ActorComponent.h" +#include "StevesLightFlicker.generated.h" + + +UENUM(BlueprintType) +enum class EStevesLightFlickerPattern : uint8 +{ + Flicker1, + Flicker2, + SlowStrongPulse, + Candle1, + Candle2, + Candle3, + FastStrobe, + SlowStrobe, + GentlePulse1, + FlourescentFlicker, + SlowPulseNoBlack, + Torch1, + Torch2, + + Custom + +}; +/** + * Helper class to get lighting flicker curves + */ +UCLASS() +class STEVESUEHELPERS_API UStevesLightFlickerHelper : public UBlueprintFunctionLibrary +{ + GENERATED_BODY() +protected: + static TMap Curves; + static TMap CustomCurves; + static FCriticalSection CriticalSection; + static const TMap StandardPatterns; + + + static void BuildCurve(EStevesLightFlickerPattern CurveType, FRichCurve& OutCurve); + static void BuildCurve(const FString& QuakeCurveChars, FRichCurve& OutCurve); + +public: + /** + * Directly evaluate a lighting curve. Alternatively, see ULightingCurveComponent. + * @param CurveType The type of curve + * @param Time The time index (0..1 period) + * @return Normalised value of the curve at this time + */ + UFUNCTION(BlueprintPure, Category="Lighting Curves") + static float EvaluateLightCurve(EStevesLightFlickerPattern CurveType, float Time); + + static const FRichCurve& GetLightCurve(EStevesLightFlickerPattern CurveType); + static const FRichCurve& GetLightCurve(const FString& CurveStr); + +}; + +DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnLightFlickerUpdate, float, LightValue); +/** + * This is like a generated version of TimelineComponent, providing a generated lighting curve. + */ +UCLASS(Blueprintable, ClassGroup=(Lights), meta=(BlueprintSpawnableComponent)) +class UStevesLightFlickerComponent : public UActorComponent +{ + GENERATED_UCLASS_BODY() + +protected: + UPROPERTY(EditAnywhere, Category="Light Flicker") + EStevesLightFlickerPattern FlickerPattern = EStevesLightFlickerPattern::Candle1; + + /// If using a custom pattern, provide your own Quake-style string of letters, a-z (a = 0, m = 1, z = 2) + UPROPERTY(EditAnywhere, Category="Light Flicker") + FString CustomFlickerPattern; + + /// Max output intensity multiplier value. Defaults to 2 since that's what Quake used! + /// We can *very slightly* exceed this max with 'z' as per standard Quake where z was 2.08 + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Light Flicker") + float MaxValue = 2; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Light Flicker") + float MinValue = 0; + + /// Playback speed + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Light Flicker") + float Speed = 1; + + /// Whether to auto-start + UPROPERTY(EditAnywhere, Category="Light Flicker") + bool bAutoPlay = true; + + UPROPERTY(ReplicatedUsing=OnRep_TimePos) + float TimePos; + float CurrentValue; + + const FRichCurve* Curve; + + UFUNCTION() + void OnRep_TimePos(); + void ValueUpdate(); + void GenerateCurveAndPlay(); + +public: + + UPROPERTY(BlueprintAssignable) + FOnLightFlickerUpdate OnLightFlickerUpdate; + + UFUNCTION(BlueprintCallable, Category="Light Flicker") + void Play(bool bResetTime = false); + UFUNCTION(BlueprintCallable, Category="Light Flicker") + void Pause(); + UFUNCTION(BlueprintPure, Category="Light Flicker") + float GetCurrentValue() const; + virtual void BeginPlay() override; + virtual void TickComponent(float DeltaTime, + ELevelTick TickType, + FActorComponentTickFunction* ThisTickFunction) override; + + /// Change the flicker pattern dynamically + UFUNCTION(BlueprintCallable, Category="Light Flicker") + void SetFlickerPattern(EStevesLightFlickerPattern Pattern, const FString& CustomPatternString = FString("")); + + /// Get the flicker pattern + UFUNCTION(BlueprintPure, Category="Light Flicker") + EStevesLightFlickerPattern GetFlickerPattern(FString& CustomString); + + +}; \ No newline at end of file diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesLogHelpers.h b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesLogHelpers.h new file mode 100644 index 00000000..bb744b15 --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesLogHelpers.h @@ -0,0 +1,17 @@ +#pragma once + +/// Log once per session only +#define STEVES_LOG_ONCE(CategoryName, Verbosity, Format, ...) \ +{ \ + static bool bLogged = false; \ + UE_CLOG(!bLogged, CategoryName, Verbosity, Format, ##__VA_ARGS__); \ + bLogged = true; \ +} + +/// Suppress repeat logs if the value is the same as last time at the call site +#define STEVES_LOG_NOREPEAT(TypeName, Value, CategoryName, Verbosity, Format, ...) \ +{ \ + static TOptional LastValue; \ + UE_CLOG(!LastValue.IsSet() || LastValue != Value, CategoryName, Verbosity, Format, ##__VA_ARGS__); \ + LastValue = Value; \ +} diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesMathHelpers.h b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesMathHelpers.h new file mode 100644 index 00000000..9af014c6 --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesMathHelpers.h @@ -0,0 +1,193 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +#pragma once +#include +#include "Math/UnrealMathUtility.h" +#include "Math/MathFwd.h" +#include "CollisionShape.h" +#include "Engine/EngineTypes.h" + +struct FKConvexElem; + +/// Helper maths routines that UE4 is missing, all static +class STEVESUEHELPERS_API StevesMathHelpers +{ +public: + + /** + * @brief Return whether a sphere overlaps a cone + * @param ConeOrigin Origin of the cone + * @param ConeDir Direction of the cone, must be normalised + * @param ConeHalfAngle Half-angle of the cone, in radians + * @param Distance Length of the cone + * @param SphereCentre Centre of the sphere + * @param SphereRadius Radius of the sphere + * @return True if the sphere overlaps the cone + */ + static bool SphereOverlapCone(const FVector& ConeOrigin, const FVector& ConeDir, float ConeHalfAngle, float Distance, const FVector& SphereCentre, float SphereRadius) + { + // Algorithm from https://www.geometrictools.com/GTE/Mathematics/IntrSphere3Cone3.h + + const float SinHalfAngle = FMath::Sin(ConeHalfAngle); + const float InvSinHalfAngle = 1.f/SinHalfAngle; + + const FVector U = ConeOrigin - (SphereRadius * InvSinHalfAngle) * ConeDir; + const FVector CmU = SphereCentre - U; + const float AdCmU = FVector::DotProduct(ConeDir, CmU); + if (AdCmU > 0) + { + const float CosHalfAngle = FMath::Cos(ConeHalfAngle); + const float CosHalfAngleSq = CosHalfAngle * CosHalfAngle; + + const float sqrLengthCmU = FVector::DotProduct(CmU, CmU); + if (AdCmU * AdCmU >= sqrLengthCmU * CosHalfAngleSq) + { + const FVector CmV = SphereCentre - ConeOrigin; + const float AdCmV = FVector::DotProduct(ConeDir, CmV); + if (AdCmV < -SphereRadius) + { + return false; + } + + if (AdCmV > Distance + SphereRadius) + { + return false; + } + + const float rSinAngle = SphereRadius * SinHalfAngle; + if (AdCmV >= -rSinAngle) + { + if (AdCmV <= Distance - rSinAngle) + { + return true; + } + else + { + const float TanHalfAngle = FMath::Tan(ConeHalfAngle); + + const FVector barD = CmV - Distance * ConeDir; + const float lengthAxBarD = FVector::CrossProduct(ConeDir, barD).Size(); + const float hmaxTanAngle = Distance * TanHalfAngle; + if (lengthAxBarD <= hmaxTanAngle) + { + return true; + } + + const float AdBarD = AdCmV - Distance; + const float diff = lengthAxBarD - hmaxTanAngle; + const float sqrLengthCmBarK = AdBarD * AdBarD + diff * diff; + return sqrLengthCmBarK <= SphereRadius * SphereRadius; + } + } + else + { + const float sqrLengthCmV = FVector::DotProduct(CmV, CmV); + return sqrLengthCmV <= SphereRadius * SphereRadius; + } + } + } + return false; + } + + /** + * Explicitly test the overlap of any collision shape with a convex element. + * @param Convex The convex element + * @param ConvexTransform The world transform of the convex element + * @param Shape The test shape + * @param ShapePos The test shape world position + * @param ShapeRot The test shape world rotation + * @param OutResult Details of the result if returning true + * @return Whether this shape overlaps the convex element + */ + static bool OverlapConvex(const FKConvexElem& Convex, + const FTransform& ConvexTransform, + const FCollisionShape& Shape, + const FVector& ShapePos, + const FQuat& ShapeRot, + FMTDResult& OutResult); + + /** + * Return the distance to a convex polygon in 2D where points are in the same space + * @param ConvexPoints Points on the convex polygon, anti-clockwise order, in a chosen space + * @param LocalPoint Point to test, in same space as convex points + * @return The distance to this convex polygon in 2D space. <= 0 if inside + */ + static float GetDistanceToConvex2D(const TArray& ConvexPoints, + const FVector& LocalPoint); + /** + * Return the distance to a convex polygon in 2D where points are in the same space + * @param ConvexPoints Points on the convex polygon, anti-clockwise order, in a chosen space + * @param LocalPoint Point to test, in same space as convex points + * @return The distance to this convex polygon in 2D space. <= 0 if inside + */ + static float GetDistanceToConvex2D(const TArray& ConvexPoints, + const FVector2f& LocalPoint); + /** + * Return the distance to a convex polygon in 2D world space, converting between spaces + * @param ConvexPoints Points on the convex polygon, anti-clockwise order, in local space + * @param ConvexTransform World transform for convex polygon + * @param WorldPoint Point in world space + * @return The distance to this convex polygon in 2D space. <= 0 if inside + */ + static float GetDistanceToConvex2D(const TArray& ConvexPoints, + const FTransform& ConvexTransform, + const FVector& WorldPoint) + { + checkf(ConvexTransform.GetMaximumAxisScale() == ConvexTransform.GetMinimumAxisScale(), TEXT("Non-uniform scale not supported in GetDistanceToConvex2D")); + const FVector LocalPoint = ConvexTransform.InverseTransformPosition(WorldPoint); + + // Need to rescale distance back up to world scale, only uniform scale supported for simplicity + return GetDistanceToConvex2D(ConvexPoints, LocalPoint) * ConvexTransform.GetScale3D().X; + } + + /** + * Returns whether a 2D point is inside a triangle + * @param p Point to test + * @param v0 First triangle point + * @param v1 Second triangle point + * @param v2 Third triangle point + * @return Whether point p is inside the triangle. + */ + static bool IsPointInTriangle2D(const FVector& p, + const FVector2f& v0, + const FVector2f& v1, + const FVector2f& v2) + { + const float s = (v0.X - v2.X) * (p.Y - v2.Y) - (v0.Y - v2.Y) * (p.X - v2.X); + const float t = (v1.X - v0.X) * (p.Y - v0.Y) - (v1.Y - v0.Y) * (p.X - v0.X); + + if ((s < 0) != (t < 0) && s != 0 && t != 0) + return false; + + const float d = (v2.X - v1.X) * (p.Y - v1.Y) - (v2.Y - v1.Y) * (p.X - v1.X); + return d == 0 || (d < 0) == (s + t <= 0); + } + + /** + * Function that tries to fill a 2D area with the largest rectangles it can. The area is abstractly defined as a boundary + * index area with start X/Y and width/height, and will call back the CellIncludeFunc to determine whether a given + * cell index X/Y should be considered valid to include in a rectangle. This means you can define irregular grids of + * "valid" cells, and this function will fill the area with the largest rectangles it can while staying out of "invalid" + * cells. + * If you return "true" from every call to your CellIncludeFunc then the result will be a single rectangle covering + * the entire area. It's expected that you will return "false" for some X/Y combinations and that will cause the area + * to be split into multiple rectangles. + * The returned rectangles will not overlap, and the entire valid area will be filled. + * @param StartX The start X index. This is defined by your own data, so you can address a subset if you want. + * @param StartY The start Y index.This is defined by your own data, so you can address a subset if you want. + * @param Width The width of the area to fill. This is defined by your own data, so you can address a subset if you want. + * @param Height The height of the area to fill. This is defined by your own data, so you can address a subset if you want. + * @param CellIncludeFunc Your function which given an X/Y cell index, must return true if that cell is valid to be + * included in a rectangle. + * @param OutRects Array of rectangles which this function should append results to. Will not be cleared before adding. + * @return The number of rectangles added by this call. Each rectangle is a min/max inclusive X/Y value. + */ + static int Fill2DRegionWithRectangles(int StartX, + int StartY, + int Width, + int Height, + std::function CellIncludeFunc, + TArray& OutRects); + + +}; \ No newline at end of file diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesPluginSettings.h b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesPluginSettings.h new file mode 100644 index 00000000..cda187f4 --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesPluginSettings.h @@ -0,0 +1,28 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +#pragma once + +#include "CoreMinimal.h" +#include "Engine/DeveloperSettings.h" + +#include "StevesPluginSettings.generated.h" + +/** +* Settings for the plug-in. +*/ +UCLASS(config=Engine, DefaultConfig) +class STEVESUEHELPERS_API UStevesPluginSettings : public UDeveloperSettings +{ + GENERATED_BODY() + +public: + UPROPERTY(config, EditAnywhere, Category = StevesUEHelpers, meta = (ToolTip = "Whether to automatically hide the mouse cursor and move it offscreen when using a gamepad")) + bool bHideMouseWhenGamepadUsed = true; + + /// Which directories to search for Enhanced Input Actions when referenced just by name in e.g. Rich Text Decorator + UPROPERTY(config, EditAnywhere, Category = StevesUEHelpers, meta = (DisplayName = "Directories to search for Enhanced Input Actions", RelativeToGameContentDir, LongPackageName)) + TArray EnhancedInputActionSearchDirectories; + + UStevesPluginSettings() {} + +}; diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesPooledActor.h b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesPooledActor.h new file mode 100644 index 00000000..a78f8192 --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesPooledActor.h @@ -0,0 +1,41 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +#pragma once +#include "CoreMinimal.h" +#include "UObject/Interface.h" +#include "StevesPooledActor.generated.h" + +UINTERFACE(MinimalAPI) +class UStevesPooledActor : public UInterface +{ + GENERATED_BODY() +}; + + +/** + * Optional interface for pooled actors to implement. You don't have to do this, but if you implement this interface + * for actors you add/remove from UStevesPooledActorSystem, you get additional calls to let you know when that actor is + * being added or removed from the pool. + */ +class STEVESUEHELPERS_API IStevesPooledActor +{ + GENERATED_BODY() +public: + + /** + * This function is called when this actor is added to the pool. Implement this if you want to perform actions to disable the + * actor other than the default ones (hiding the actor, disabling collision / physics on the root component, moving to a storage location). + */ + UFUNCTION(BlueprintCallable, BlueprintNativeEvent, Category="Pooling") + void DeactivateOnAddedToPool(); + + /** + * This function is called when the actor is removed from the pool, to be re-used again. Implement this to re-activate + * or reset the actor; the default behaviour is just to unhide the actor (collision / physics are not re-enabled automatically). + * You could also do this in the caller to UStevesPooledActorSystem::GetPooledActor, but this interface is here if + * you want to do it on the object itself. + */ + UFUNCTION(BlueprintCallable, BlueprintNativeEvent, Category="Pooling") + void ReactivateOnRemovedFromPool(); + +}; \ No newline at end of file diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesPooledActorSystem.h b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesPooledActorSystem.h new file mode 100644 index 00000000..818c604a --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesPooledActorSystem.h @@ -0,0 +1,119 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license + +#pragma once + +#include "CoreMinimal.h" +#include "Containers/Deque.h" +#include "Subsystems/WorldSubsystem.h" +#include "Engine/World.h" +#include "StevesPooledActorSystem.generated.h" + +/** + * General actor pooling system + */ +UCLASS() +class STEVESUEHELPERS_API UStevesPooledActorSystem : public UWorldSubsystem +{ + GENERATED_BODY() +protected: + TMap>> Pools; + + /// The location that actors in the pool are stored at + UPROPERTY(BlueprintReadWrite, Category="Pooling") + FVector StorageLocation = FVector(0,0,-100000); + + TDeque>* GetPool(UClass* Class, bool bCreate); + void DisableActor(AActor* Actor); + void ReviveActor(AActor* Actor, const FVector& Location, const FRotator& Rotator); + + AActor* SpawnNewActor(UClass* ActorClass, const FVector& Location, const FRotator& Rotation); + void DrainPool(TDeque>* pPool, int NumberToKeep); + +public: + + UFUNCTION(BlueprintCallable, Category="Pooling") + static UStevesPooledActorSystem* Get(const UObject* WorldContext); + + /// Get the location that actors in the pool are stored at + const FVector& GetStorageLocation() const + { + return StorageLocation; + } + + /// Set the location that actors are stored at when in the pool + void SetStorageLocation(const FVector& Loc) + { + this->StorageLocation = Loc; + } + + /** + * Add an actor to the pool. Will make the actor invisible, teleport it to the storage location, and disable all + * physics. You must NOT destroy this actor! + * @param Actor + */ + UFUNCTION(BlueprintCallable, Category="Pooling") + void AddActorToPool(AActor* Actor); + /** + * Release an actor back to the pool, instead of destroying it. Will make the actor invisible, teleport it to a + * far away location, and disable physics. You must NOT destroy this actor! + * @param Actor + */ + UFUNCTION(BlueprintCallable, Category="Pooling") + void ReleasePooledActor(AActor* Actor); + + /** + * Re-use or spawn an actor of a given class. Note that if the actor is re-used, you will need to re-enable physics + * yourself since we do not store that state or assume it will be the same. It will be teleported to the correct location and set visible. + * @param ActorClass The class of the actor + * @param Location Location in the world + * @param Rotation Rotation in the world + * @param bWasReUsed This is set to true if the actor was re-used from the pool, and therefore will need to have its + * physics reset by the caller (if not done by an IStevesPooledActor implementation) + * @return Spawned or re-used actor. Remember to check physics settings, both will be disabled if re-used. + */ + UFUNCTION(BlueprintCallable, Category="Pooling") + AActor* GetPooledActor(TSubclassOf ActorClass, + const FVector& Location, + const FRotator& Rotation, + bool& bWasReUsed); + + + template< class T > + T* GetPooledActor(UClass* Class, const FVector& Location, const FRotator& Rotation, bool& bOutWasReused) + { + return Cast(GetPooledActor(Class, Location, Rotation, bOutWasReused)); + } + + template< class T > + T* GetPooledActor(UClass* Class, const FVector& Location, const FRotator& Rotation) + { + bool Dummy; + return Cast(GetPooledActor(Class, Location, Rotation, Dummy)); + } + + /** + * Pre-warm the actor pool by creating a number of instances and making them invisible + * @param ActorClass The class of actors to pre-warm the pool with + * @param Count The number of actors to pre-create + */ + UFUNCTION(BlueprintCallable, Category="Pooling") + void PreWarmActorPool(TSubclassOf ActorClass, int Count = 20); + + /** + * Drain the actor pool for a single class of actor, destroying any surplus actors. + * @param ActorClass The actor class to drain the pool for + * @param NumberToKeep The number of actors to keep, if any + */ + UFUNCTION(BlueprintCallable, Category="Pooling") + void DrainActorPool(TSubclassOf ActorClass, int NumberToKeep = 0); + + /** + * Drain all actor pools, destroying all pooled actors. + */ + UFUNCTION(BlueprintCallable, Category="Pooling") + void DrainAllActorPools(); + + virtual void Deinitialize() override; + +}; diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesReplicatedPhysicsActor.h b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesReplicatedPhysicsActor.h new file mode 100644 index 00000000..4168cece --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesReplicatedPhysicsActor.h @@ -0,0 +1,23 @@ +// Copyright Steve Streeting +// Licensed under the MIT License (see License.txt) +#pragma once + +#include "CoreMinimal.h" +#include "Engine/StaticMeshActor.h" +#include "StevesReplicatedPhysicsActor.generated.h" + +/// A static mesh actor which is simulated on the server and replicated to clients. +/// The results will be pretty smooth on the clients due to a very specific set of settings. +/// You MUST spawn this actor ONLY on the server! +UCLASS(Blueprintable) +class STEVESUEHELPERS_API AStevesReplicatedPhysicsActor : public AStaticMeshActor +{ + GENERATED_BODY() + +public: + AStevesReplicatedPhysicsActor(const FObjectInitializer& ObjInit); + +protected: + virtual void BeginPlay() override; + bool IsServer() const; +}; diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesShuffleBag.h b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesShuffleBag.h new file mode 100644 index 00000000..2129067b --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesShuffleBag.h @@ -0,0 +1,260 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license + +#pragma once + +#include "CoreMinimal.h" +#include "Math/RandomStream.h" +#include "StevesShuffleBag.generated.h" + +/// A shufflebag is an array that contains a certain number of items, and can randomly withdraw one +/// at a time until there are none left. No repeat items will be retrieved until the bag is empty. +/// Requires a type T which is the contained item (keep it small, will be copied around), and optionally +/// TRandStream which must be a type which implements the same interface as FRandomStream but may generate +/// numbers differently. +template +struct FStevesShuffleBag +{ +protected: + TArray Bag; + TRandStream RandomStream; + // This is the dividing line between items still in the bag and those which have already been pulled. + // Any index < SentinelIndex is an item in the bag, any >= SentinelIndex have already been pulled. + int SentinelIndex; + + +public: + /// Construct using a zero seed & standard capacity + FStevesShuffleBag() + { + Init(0); + } + + /// Construct using a known seed & capacity + FStevesShuffleBag(int32 Seed, int Capacity = 32) + { + Init(Seed); + } + + + /// (Re-)initialise the shuffle bag, emptying its contents and resetting the seed. + void Init(int32 Seed, int Capacity = 32) + { + Bag.Reset(Capacity); + RandomStream.Initialize(Seed); + SentinelIndex = -1; + } + + /// Reserve space for a given number of entries + void Reserve(int Capacity) + { + Bag.Reserve(Capacity); + } + + /** + * Add one or more copies of an item to the bag. + * @param NewItem The new item to add + * @param Count The number of copies of this item to add. You can control the probability of items + * by adding multiple of the same thing. + * @note If you have already started pulling items from the bag, these items will NOT be available + * to be pulled until the bag is next re-filled. They will count as having been pulled already + * (so preferably add all your items before you start pulling items from the bag) + */ + void Add(const T& NewItem, int Count = 1) + { + for (int i = 0; i < Count; ++i) + { + Bag.Add(NewItem); + } + } + + /// Remove one instance of an item from the bag and discard it. This is different to pulling the + /// item from the bag since it will not re-enter the bag on Reset. The item may have been pulled + /// already. + /// Returns whether any item was removed + bool Remove(const T& Item) + { + int Index = Bag.IndexOfByKey(Item); + if (Index != INDEX_NONE) + { + Bag.RemoveAt(Index); + if (Index < SentinelIndex) + { + // This item hadn't been pulled yet + --SentinelIndex; + } + return true; + } + + return false; + } + + /// Remove all instance of an item from the bag and discard them. This is different to pulling the + /// items from the bag since they will not re-enter the bag on Reset. + /// Returns the number of items removed. + int RemoveAll(const T& Item) + { + int RemovedCount = 0; + while (Remove(Item)) + { + ++RemovedCount; + } + return RemovedCount; + } + + /// Empty the bag, discarding the contents + void Empty() + { + // Keep allocations + Bag.Reset(); + SentinelIndex = -1; + } + + /// Reset the bag, returning all previously retrieved contents to the bag + void Reset() + { + // Any items < SentinelIndex are in the bag + SentinelIndex = Bag.Num(); + } + + /// Pull a random item from those remaining in the bag. If the bag is empty, it will be + /// refilled using the tokens that were previously pulled (via Reset). + T Next() + { + if (SentinelIndex <= 0) + { + // Empty / new bag + Reset(); + + } + + // Empty bag + if (SentinelIndex == 0) + { + return T(); + } + + // rand max is inclusive, and we need to exclude >= SentinelIndex + int NextIdx = RandomStream.RandRange(0, SentinelIndex - 1); + // Copy out + T Ret = Bag[NextIdx]; + + --SentinelIndex; + if (NextIdx != SentinelIndex) { + // swap the item that's now beyond the sentinel into our slot + Bag.Swap(NextIdx, SentinelIndex); + } + return Ret; + } + + /// Get the number of items left in the bag + int GetNumRemaining() const + { + return SentinelIndex > 0 ? SentinelIndex : 0; + } + + /// Get the number of items which have already been pulled from the bag + int GetNumPulled() const + { + return GetNumTotal() - GetNumRemaining(); + } + + /// Get the number of items both remaining in the bag and which have already been pulled from it + int GetNumTotal() const + { + return Bag.Num(); + } + +}; + +/// A shufflebag is an array that contains a certain number of items, and can randomly withdraw one +/// at a time until there are none left. No repeat items will be retrieved until the bag is empty. +/// Because Blueprints can't support templated types, FStevesIndexShuffleBag provides a concretely +/// typed shuffle bag which simply provides array indices rather than values. When you pull out an +/// item, instead of pulling out the *actual* item, you pull out an index into another array. +/// You can therefore use this with any other array so long as you keep them synchronised. +/// If you want to customise the probabilities, add multiple entries of the same item to *your* array +/// before constructing the index shuffle bag. +/// Use MakeIndexShuffleBag to create. +UCLASS(BlueprintType) +class UStevesIndexShuffleBag : public UObject +{ + GENERATED_BODY() + +protected: + FStevesShuffleBag IndexBag; +public: + + UStevesIndexShuffleBag() + { + } + + /// Reinitialise the shuffle bag with a new size / seed. + UFUNCTION(BlueprintCallable, Category="Shuffle Bag") + void Reinitialise(int Count, int32 Seed) + { + IndexBag.Init(Seed, Count); + + for (int i = 0; i < Count; ++i) + { + IndexBag.Add(i); + } + } + + + /** + * Create a new shuffle bag of indexes for accessing items in an array + * @param Outer The + * @param Count The number of items in the array you'll be accessing with these indexes + * @param Seed The seed for the random number generator + * @return The new index shuffle bag + */ + UFUNCTION(BlueprintCallable, Category="Shuffle Bag") + static UStevesIndexShuffleBag* MakeIndexShuffleBag(UObject* Outer, int Count, int32 Seed = 0) + { + auto Ret = NewObject(Outer); + Ret->Reinitialise(Count, Seed); + return Ret; + } + + + + /// Get the number of items left in the bag + UFUNCTION(BlueprintCallable, BlueprintPure, Category="Shuffle Bag") + int GetNumRemaining() const + { + return IndexBag.GetNumRemaining(); + } + + /// Get the number of items which have already been pulled from the bag + UFUNCTION(BlueprintCallable, BlueprintPure, Category="Shuffle Bag") + int GetNumPulled() const + { + return IndexBag.GetNumPulled(); + } + + /// Get the number of items both remaining in the bag and which have already been pulled from it + UFUNCTION(BlueprintCallable, BlueprintPure, Category="Shuffle Bag") + int GetNumTotal() const + { + return IndexBag.GetNumTotal(); + } + + /// Reset the bag, returning all previously retrieved contents to the bag + UFUNCTION(BlueprintCallable, Category="Shuffle Bag") + void Reset() + { + IndexBag.Reset(); + } + + /// Pull a random index from those remaining in the bag. If the bag is empty, it will be + /// refilled using the indexes that were previously pulled (via Reset). + UFUNCTION(BlueprintCallable, Category="Shuffle Bag") + int Next() + { + return IndexBag.Next(); + } + + +}; + diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesSpringArmComponent.h b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesSpringArmComponent.h new file mode 100644 index 00000000..5aeb2e4d --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesSpringArmComponent.h @@ -0,0 +1,67 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license + +#pragma once + +#include "CoreMinimal.h" +#include "GameFramework/SpringArmComponent.h" +#include "StevesSpringArmComponent.generated.h" + + +UCLASS(ClassGroup=(Custom), meta=(BlueprintSpawnableComponent)) +class STEVESUEHELPERS_API UStevesSpringArmComponent : public USpringArmComponent +{ + GENERATED_BODY() +public: + /// Whether to move the camera smoothly to avoid collisions rather than jumping instantly + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category=CameraCollision) + uint32 bEnableSmoothCollisionAvoidance : 1; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category=CameraCollision) + float SmoothCollisionAvoidanceSpeed = 5; + +protected: + TOptional PrevArmLength; + + TOptional SmoothTargetOffsetTarget; + float SmoothTargetOffsetSpeed; + TOptional SmoothSocketOffsetTarget; + float SmoothSocketOffsetSpeed; + +#if WITH_EDITORONLY_DATA + UPROPERTY(Transient, EditAnywhere, Category=CameraCollision) + bool bVisualLogCameraCollision = false; +#endif + +public: + UStevesSpringArmComponent(); + + /// Smoothly change the target offset, instead of jumping + UFUNCTION(BlueprintCallable, Category="SpringArm") + void SetTargetOffsetSmooth(const FVector& NewTargetOffset, float Speed = 5); + /// Interrupt a smooth target offset change, freeze where we are + UFUNCTION(BlueprintCallable, Category="SpringArm") + void CancelTargetOffsetSmooth(); + + /// Smoothly change the socket offset, instead of jumping + UFUNCTION(BlueprintCallable, Category="SpringArm") + void SetSocketOffsetSmooth(const FVector& NewSocketOffset, float Speed = 5); + /// Interrupt a smooth socket offset change, freeze where we are + UFUNCTION(BlueprintCallable, Category="SpringArm") + void CancelSocketOffsetSmooth(); + + /// Immediately jump to where the arm wants to be, ignoring interpolation + /// Useful if you need the camera to be where it should be right now + UFUNCTION(BlueprintCallable, Category="SpringArm") + void JumpToDesiredLocation(); +protected: + virtual void UpdateDesiredArmLocation(bool bDoTrace, + bool bDoLocationLag, + bool bDoRotationLag, + float DeltaTime) override; + virtual FVector BlendLocations(const FVector& DesiredArmLocation, + const FVector& TraceHitLocation, + bool bHitSomething, + float DeltaTime) override; +}; + diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesTextureRenderTargetPool.h b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesTextureRenderTargetPool.h new file mode 100644 index 00000000..99fdd88e --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesTextureRenderTargetPool.h @@ -0,0 +1,150 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +#pragma once + +#include "CoreMinimal.h" +#include "Engine/TextureRenderTarget2D.h" +#include "Runtime/Launch/Resources/Version.h" +#include "UObject/GCObject.h" + +typedef TSharedPtr FStevesTextureRenderTargetReservationPtr; +typedef TSharedPtr FStevesTextureRenderTargetPoolPtr; + +/// Holder for an assigned texture. While this structure exists, the texture will be considered assigned +/// and will not be returned from any other request. Once this structure is destroyed the texture will +/// be free for re-use. For that reason, only pass this structure around by SharedRef/SharedPtr. +/// The texture is held by a weak pointer however, the strong pointer is held by the pool. The texture will continue +/// to be available to this reservation except if the pool is told to forcibly release textures. +struct STEVESUEHELPERS_API FStevesTextureRenderTargetReservation +{ +public: + /// The texture. May be null if the pool has forcibly reclaimed the texture prematurely + TWeakObjectPtr Texture; + TWeakPtr ParentPool; + TWeakObjectPtr CurrentOwner; + + FStevesTextureRenderTargetReservation() = default; + + FStevesTextureRenderTargetReservation(UTextureRenderTarget2D* InTexture, + FStevesTextureRenderTargetPoolPtr InParent, + const UObject* InOwner) + : Texture(InTexture), + ParentPool(InParent), + CurrentOwner(InOwner) + + { + } + + ~FStevesTextureRenderTargetReservation(); +}; + + +/** + * A pool of render target textures. To save pre-creating render textures as assets, and to control the re-use of + * these textures at runtime. + * A pool needs to be owned by a UObject, which will in turn own the textures and so will ultimately control the + * ultimate lifecycle of textures if not released specifically. + * See FCompElementRenderTargetPool for inspiration + */ +struct STEVESUEHELPERS_API FStevesTextureRenderTargetPool : public FGCObject, public TSharedFromThis +{ + +protected: + /// The name of the pool. It's possible to have more than one texture pool. + FName Name; + + struct FTextureKey + { + FIntPoint Size; + ETextureRenderTargetFormat Format; + + friend bool operator==(const FTextureKey& Lhs, const FTextureKey& RHS) + { + return Lhs.Size == RHS.Size + && Lhs.Format == RHS.Format; + } + + friend bool operator!=(const FTextureKey& Lhs, const FTextureKey& RHS) + { + return !(Lhs == RHS); + } + + friend uint32 GetTypeHash(const FTextureKey& Key) + { + return HashCombine(GetTypeHash(Key.Size), static_cast(Key.Format)); + } + + }; + + TWeakObjectPtr PoolOwner; + TMultiMap> UnreservedTextures; + TSet> ReservedTextures; + + /// Weak reverse tracking of reservations, mostly for debugging + struct FReservationInfo + { + FTextureKey Key; + TWeakObjectPtr Owner; + TWeakObjectPtr Texture; + + FReservationInfo(const FTextureKey& InKey, const UObject* InOwner, UTextureRenderTarget2D* InTexture) + : Key(InKey), + Owner(InOwner), + Texture(InTexture) + { + } + }; + TArray Reservations; + + + friend struct FStevesTextureRenderTargetReservation; + /// Release a reservation on a texture, allowing it back into the pool + /// Protected because only FStevesTextureRenderTargetReservation will need to do this. + void ReleaseReservation(UTextureRenderTarget2D* Tex); +public: + + explicit FStevesTextureRenderTargetPool(const FName& InName, UObject* InOwner) + : Name(InName), PoolOwner(InOwner) + { + } + + virtual ~FStevesTextureRenderTargetPool() override; + + const FName& GetName() const { return Name; } + + // FGCObject + virtual void AddReferencedObjects(FReferenceCollector& Collector) override; + +#if ENGINE_MAJOR_VERSION >= 5 + // FGCObject + virtual FString GetReferencerName() const override; +#endif + /** + * Reserve a texture for use as a render target. This will create a new texture target if needed. + * @param Size The dimensions of the texture + * @param Format Format of the texture + * @param Owner The UObject which will temporarily own this texture (mostly for debugging, this object won't in fact "own" it + * as per garbage collection rules, the reference is weak + * @return A shared pointer to a structure which holds the reservation for this texture. When that structure is + * destroyed, it will release the texture back to the pool. + */ + FStevesTextureRenderTargetReservationPtr ReserveTexture(FIntPoint Size, ETextureRenderTargetFormat Format, const UObject* Owner); + + /** + * Forcibly revoke reservations in this pool, either for all owners or for a specific owner. + * Reservations which are revoked will have their weak texture pointers invalidated. + * @param ForOwner If null, revoke all reservations for any owner, or if provided, just for a specific owner. + */ + void RevokeReservations(const UObject* ForOwner = nullptr); + + + /** + * + * Destroy previously created textures and free the memory. + * @param bForceAndRevokeReservations If false, only destroys unreserved textures. If true, destroys reserved textures + * as well (the weak pointer on their reservations will cease to be valid) + */ + void DrainPool(bool bForceAndRevokeReservations = false); + +}; + diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUEHelpers.h b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUEHelpers.h new file mode 100644 index 00000000..14ee9d72 --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUEHelpers.h @@ -0,0 +1,34 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +#pragma once + +#include "CoreMinimal.h" + + +#include "StevesGameSubsystem.h" +#include "Modules/ModuleManager.h" +#include "Engine/GameInstance.h" +#include "Engine/World.h" + +DECLARE_LOG_CATEGORY_EXTERN(LogStevesUEHelpers, Verbose, Verbose); + +class FStevesUEHelpers : public IModuleInterface +{ +public: + + /** IModuleInterface implementation */ + virtual void StartupModule() override; + virtual void ShutdownModule() override; +}; + +inline UStevesGameSubsystem* GetStevesGameSubsystem(UWorld* WorldContext) +{ + if (IsValid(WorldContext) && WorldContext->IsGameWorld()) + { + auto GI = WorldContext->GetGameInstance(); + if (IsValid(GI)) + return GI->GetSubsystem(); + } + + return nullptr; +} \ No newline at end of file diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/FocusSystem.h b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/FocusSystem.h new file mode 100644 index 00000000..c7c7eaa0 --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/FocusSystem.h @@ -0,0 +1,19 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +#pragma once + +#include "CoreMinimal.h" + +DECLARE_LOG_CATEGORY_EXTERN(LogFocusSystem, Log, All) + +class FFocusSystem +{ +protected: + TArray> ActiveAutoFocusWidgets; + + TWeakObjectPtr GetHighestFocusPriority(); +public: + void FocusableWidgetConstructed(UFocusableUserWidget* Widget); + void FocusableWidgetDestructed(UFocusableUserWidget* Widget); + +}; diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/FocusableButton.h b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/FocusableButton.h new file mode 100644 index 00000000..a71f9b97 --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/FocusableButton.h @@ -0,0 +1,74 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +#pragma once + +#include "CoreMinimal.h" +#include "Components/Button.h" +#include "UObject/ObjectMacros.h" +#include "Styling/SlateTypes.h" +#include "Widgets/SWidget.h" + +#include "FocusableButton.generated.h" + +class SFocusableButton; + +DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnButtonFocusReceivedEvent); +DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnButtonFocusLostEvent); + +/** +* This is a simple subclass of UButton to provide some missing features +* +* * Focus events +* * Focus style based on hover style +* * Assign focus to self on hover to prevent double-highlighting +*/ +UCLASS() +class STEVESUEHELPERS_API UFocusableButton : public UButton +{ + GENERATED_UCLASS_BODY() + +public: + UPROPERTY(BlueprintReadWrite, EditAnywhere, Category="Focus") + bool bUseHoverStyleWhenFocussed = true; + + UPROPERTY(BlueprintAssignable, Category="Button|Event") + FOnButtonFocusReceivedEvent OnFocusReceived; + + UPROPERTY(BlueprintAssignable, Category="Button|Event") + FOnButtonFocusLostEvent OnFocusLost; + + UPROPERTY(BlueprintReadWrite, EditAnywhere, Category="Focus") + bool bTakeFocusOnHover = true; + + UPROPERTY(BlueprintReadWrite, EditAnywhere, Category="Focus") + bool bLoseFocusOnUnhover = true; + + // Simulate a button press + UFUNCTION(BlueprintCallable, Category="Focus") + void SimulatePress(); + // Simulate a button release + UFUNCTION(BlueprintCallable, Category="Focus") + void SimulateRelease(); + +protected: + FButtonStyle FocussedStyle; + + void SlateHandleFocusReceived(); + void SlateHandleFocusLost(); + + void ApplyFocusStyle(); + void UndoFocusStyle(); + void SlateHandleHovered(); + void SlateHandleUnhovered(); + + void Unfocus() const; + + virtual TSharedRef RebuildWidget() override; + + + /// Update the focussed style based on changes made to the default widget style. + /// Call this if you make runtime changes to the base style of this button. + /// Needed because we can't override SetStyle + UFUNCTION(BlueprintCallable, BlueprintNativeEvent, Category="Focus") + void RefreshFocussedStyle(); +}; \ No newline at end of file diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/FocusableCheckBox.h b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/FocusableCheckBox.h new file mode 100644 index 00000000..b9cad997 --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/FocusableCheckBox.h @@ -0,0 +1,75 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license + +#pragma once + +#include "CoreMinimal.h" +#include "Components/CheckBox.h" +#include "FocusableCheckBox.generated.h" + + +DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnCheckBoxHoveredEvent); +DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnCheckBoxUnhoveredEvent); +DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnCheckBoxFocusReceivedEvent); +DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnCheckBoxFocusLostEvent); +/** +* This is a simple subclass of UCheckBox to provide some missing features +* +* * Focus events +* * Hover events +* * Focus style based on hover style +* * Assign focus to self on hover to prevent double-highlighting +*/ +UCLASS() +class STEVESUEHELPERS_API UFocusableCheckBox : public UCheckBox +{ + GENERATED_BODY() + +public: + UPROPERTY(BlueprintReadWrite, EditAnywhere, Category="Focus") + bool bUseHoverStyleWhenFocussed = true; + + UPROPERTY( BlueprintAssignable, Category = "CheckBox|Event" ) + FOnCheckBoxHoveredEvent OnHovered; + + UPROPERTY( BlueprintAssignable, Category = "CheckBox|Event" ) + FOnCheckBoxUnhoveredEvent OnUnhovered; + + + UPROPERTY(BlueprintAssignable, Category="CheckBox|Event") + FOnCheckBoxFocusReceivedEvent OnFocusReceived; + + UPROPERTY(BlueprintAssignable, Category="CheckBox|Event") + FOnCheckBoxFocusLostEvent OnFocusLost; + + UPROPERTY(BlueprintReadWrite, EditAnywhere, Category="Focus") + bool bTakeFocusOnHover = true; + + UPROPERTY(BlueprintReadWrite, EditAnywhere, Category="Focus") + bool bLoseFocusOnUnhover = true; + +protected: + FCheckBoxStyle FocussedStyle; + + void SlateHandleFocusReceived(); + void SlateHandleFocusLost(); + + void ApplyFocusStyle(); + void UndoFocusStyle(); + void SlateHandleHovered(); + void SlateHandleUnhovered(); + + void Unfocus() const; + + virtual TSharedRef RebuildWidget() override; + +public: + + /// Update the focussed style based on changes made to the default widget style. + /// Call this if you make runtime changes to the base style of this checkbox. + /// Needed because we can't override SetStyle + UFUNCTION(BlueprintCallable, BlueprintNativeEvent, Category="Focus") + void RefreshFocussedStyle(); + + +}; diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/FocusableInputInterceptorUserWidget.cpp b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/FocusableInputInterceptorUserWidget.cpp new file mode 100644 index 00000000..6c2e140f --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/FocusableInputInterceptorUserWidget.cpp @@ -0,0 +1,31 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +#include "FocusableInputInterceptorUserWidget.h" +#include "Framework/Application/SlateApplication.h" + +void UFocusableInputInterceptorUserWidget::NativeConstruct() +{ + Super::NativeConstruct(); + + if (!InputPreprocessor.IsValid()) + { + InputPreprocessor = MakeShareable(new FUiInputPreprocessor()); + InputPreprocessor->OnUiKeyDown.BindUObject(this, &UFocusableInputInterceptorUserWidget::HandleKeyDownEvent); + } + FSlateApplication::Get().RegisterInputPreProcessor(InputPreprocessor); + +} + +void UFocusableInputInterceptorUserWidget::NativeDestruct() +{ + Super::NativeDestruct(); + + FSlateApplication::Get().UnregisterInputPreProcessor(InputPreprocessor); +} + + +bool UFocusableInputInterceptorUserWidget::HandleKeyDownEvent(const FKeyEvent& InKeyEvent) +{ + // Do nothing by default + return false; +} diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/FocusableInputInterceptorUserWidget.h b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/FocusableInputInterceptorUserWidget.h new file mode 100644 index 00000000..b415ff93 --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/FocusableInputInterceptorUserWidget.h @@ -0,0 +1,45 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +#pragma once + +#include "CoreMinimal.h" + +#include "FocusableUserWidget.h" +#include "Framework/Application/IInputProcessor.h" + +#include "FocusableInputInterceptorUserWidget.generated.h" + +UCLASS(Blueprintable, BlueprintType) +class STEVESUEHELPERS_API UFocusableInputInterceptorUserWidget : public UFocusableUserWidget +{ + GENERATED_BODY() + + // Nested class which we'll use to poke into input events before anything else eats them + // Without this it seems impossible to pick up e.g. Gamepad "B" button with UMG up + // It means we hardcode the controls for menus but that's OK in practice + class FUiInputPreprocessor : public IInputProcessor, public TSharedFromThis + { + public: + DECLARE_DELEGATE_RetVal_OneParam(bool, FOnUiKeyDown, const FKeyEvent&); + FOnUiKeyDown OnUiKeyDown; + + virtual bool HandleKeyDownEvent(FSlateApplication& SlateApp, const FKeyEvent& InKeyEvent) override + { + return OnUiKeyDown.Execute(InKeyEvent); + } + // Required by IInputProcessor but we don't need + virtual void Tick(const float DeltaTime, FSlateApplication& SlateApp, TSharedRef Crs) override{} + }; + +protected: + TSharedPtr InputPreprocessor; + +public: + + virtual void NativeConstruct() override; + virtual void NativeDestruct(); + + UFUNCTION() + virtual bool HandleKeyDownEvent(const FKeyEvent& InKeyEvent); + +}; diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/FocusablePanel.h b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/FocusablePanel.h new file mode 100644 index 00000000..7c1ef069 --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/FocusablePanel.h @@ -0,0 +1,81 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +#pragma once + +#include "CoreMinimal.h" +#include "FocusableUserWidget.h" + +#include "FocusablePanel.generated.h" + +/// Base class for a UI Panel which has the concept of having focus, delegated to one of its children. +/// When told, it can initialise focus to a default widget. It can also remember which of its children +/// are currently focussed and restore that later. +/// Calling SetFocusProperly does the default behaviour of preferring previous but falling back on the default. +UCLASS() +class STEVESUEHELPERS_API UFocusablePanel : public UFocusableUserWidget +{ + GENERATED_BODY() + + +public: + /// The name of the widget which will be initially focussed by default + /// This is a name because we can't link directly at edit time + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Focus") + FName InitialFocusWidgetName; + + // I'd love to make the above a drop-down but it's a lot of faff + // See Engine\Source\Editor\UMGEditor\Private\Customizations\WidgetNavigationCustomization.cpp + // Specifically OnGenerateWidgetList + + /** + * @brief Set the current focus to the initial focus widget + * @return Whether the focus was successfully set + */ + UFUNCTION(BlueprintCallable, Category="Focus") + bool SetFocusToInitialWidget(); + + /** + * @brief Get's the desired focus widget. Allows implementation in BP for dynamically generated menus + * @return The initial focus widget + */ + UFUNCTION(BlueprintNativeEvent, Category="Focus") + UWidget* GetInitialFocusWidget(); + + /** + * @brief Set the desired initial focus widget. Call to set the initial widget dynamically without overriding GetInitialFocusWidget + * @param NewInitialFocus The initial focus widget + */ + UFUNCTION(BlueprintCallable, Category="Focus") + void SetInitialFocusWidget(UWidget* NewInitialFocus); + + /** + * @brief Try to restore focus to the previously focussed child + * @return Whether the focus was successfully set + */ + UFUNCTION(BlueprintCallable, Category="Focus") + bool RestorePreviousFocus() const; + + /** + * @brief Try to save the currently focussed child as something that can be restored later. + * @return Whether focus was saved + */ + UFUNCTION(BlueprintCallable, Category="Focus") + bool SavePreviousFocus(); + + + /// When SetFocusProperly is called, either restores previous selection or gives it to the initial selection + virtual void SetFocusProperly_Implementation() override; +protected: + + /// The widget that should get the focus on init if in keyboard / gamepad mode + /// Looked up at runtime from the FName + TWeakObjectPtr InitialFocusWidget; + + /// Previously focussed child which can be restored + TWeakObjectPtr PreviousFocusWidget; + + virtual void NativeConstruct() override; + virtual void NativeDestruct() override; + + +}; diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/FocusableSlider.h b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/FocusableSlider.h new file mode 100644 index 00000000..b51ec29f --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/FocusableSlider.h @@ -0,0 +1,68 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license + +#pragma once + +#include "CoreMinimal.h" +#include "Components/Slider.h" +#include "FocusableSlider.generated.h" + +DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnSliderHoveredEvent); +DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnSliderUnhoveredEvent); +DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnSliderFocusReceivedEvent); +DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnSliderFocusLostEvent); + +/** + * A focusable version of the slider control, which highlights things like hover when the controller + * has the focus on it. + */ +UCLASS() +class STEVESUEHELPERS_API UFocusableSlider : public USlider +{ + GENERATED_BODY() + +public: + UPROPERTY(BlueprintReadWrite, EditAnywhere, Category="Focus") + bool bUseHoverStyleWhenFocussed = true; + + UPROPERTY( BlueprintAssignable, Category = "Slider|Event" ) + FOnSliderHoveredEvent OnHovered; + + UPROPERTY( BlueprintAssignable, Category = "Slider|Event" ) + FOnSliderUnhoveredEvent OnUnhovered; + + UPROPERTY(BlueprintAssignable, Category="Slider|Event") + FOnSliderFocusReceivedEvent OnFocusReceived; + + UPROPERTY(BlueprintAssignable, Category="Slider|Event") + FOnSliderFocusLostEvent OnFocusLost; + + UPROPERTY(BlueprintReadWrite, EditAnywhere, Category="Focus") + bool bTakeFocusOnHover = true; + + UPROPERTY(BlueprintReadWrite, EditAnywhere, Category="Focus") + bool bLoseFocusOnUnhover = true; + +protected: + FSliderStyle FocussedStyle; + + void SlateHandleFocusReceived(); + void SlateHandleFocusLost(); + + void ApplyFocusStyle(); + void UndoFocusStyle(); + void SlateHandleHovered(); + void SlateHandleUnhovered(); + + void Unfocus() const; + + virtual TSharedRef RebuildWidget() override; + +public: + + /// Update the focussed style based on changes made to the default widget style. + /// Call this if you make runtime changes to the base style of this checkbox. + /// Needed because we can't override SetStyle + UFUNCTION(BlueprintCallable, BlueprintNativeEvent, Category="Focus") + void RefreshFocussedStyle(); +}; diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/FocusableUserWidget.h b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/FocusableUserWidget.h new file mode 100644 index 00000000..c09a4499 --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/FocusableUserWidget.h @@ -0,0 +1,54 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +#pragma once + +#include "CoreMinimal.h" +#include "Blueprint/UserWidget.h" + +#include "FocusableUserWidget.generated.h" + +// Hacky intermediate type for UUserWidget so that we can have focusable child widgets via SetFocusProperly +UCLASS() +class STEVESUEHELPERS_API UFocusableUserWidget : public UUserWidget +{ + GENERATED_BODY() + +protected: + + /// If enabled, this widget will opt-in to the list of widgets which can be given focus + /// automatically when another UFocusableUserWidget with focus is removed from the viewport. + /// Useful for making sure something has the focus at all times without having to have cross-dependencies + /// between UI parts, or events everywhere + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Focus") + bool bEnableAutomaticFocus = false; + + /// If bEnableAutomaticFocus is enabled, then this is the focus priority associated with this widget. + /// In the event that there is more than one auto focus widget available, the highest priority widget will win. + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Focus") + int AutomaticFocusPriority = 0; + + virtual FReply NativeOnFocusReceived(const FGeometry& InGeometry, const FFocusEvent& InFocusEvent) override; + +public: + /// UWidget::SetFocus is not virtual FFS. This does the same as SetFocus by default but can be overridden, + /// e.g. to delegate focus to specific children + UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category="Focus") + void SetFocusProperly(); + + /// Whether this widget is *currently* requesting focus. Default is to use IsAutomaticFocusEnabled but subclasses + /// may override this to be volatile + UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category="Focus") + bool IsRequestingFocus() const; + + /// Tell this widget to take the focus if it desires to + UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category="Focus") + bool TakeFocusIfDesired(); + + virtual bool IsAutomaticFocusEnabled() const { return bEnableAutomaticFocus; } + virtual int GetAutomaticFocusPriority() const { return AutomaticFocusPriority; } + +protected: + virtual void NativeConstruct() override; + virtual void NativeDestruct() override; + +}; diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/InputImage.h b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/InputImage.h new file mode 100644 index 00000000..6702ea82 --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/InputImage.h @@ -0,0 +1,130 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +#pragma once + +#include "CoreMinimal.h" + +#include "UiTheme.h" +#include "InputCoreTypes.h" +#include "InputAction.h" +#include "Components/Image.h" +#include "StevesHelperCommon.h" +#include "Tickable.h" +#include "InputImage.generated.h" + +/// A special widget containing an image which populates itself based on an input action / axis and can dynamically +/// change based on the active input method. +UCLASS() +class STEVESUEHELPERS_API UInputImage : public UImage, public FTickableGameObject +{ + GENERATED_BODY() + +protected: + /// What type of an input binding this image should look up + UPROPERTY(EditAnywhere, Category="InputImage") + EInputBindingType BindingType; + + /// If BindingType is Action/Axis, the name of it + UPROPERTY(EditAnywhere, Category="InputImage") + FName ActionOrAxisName; + + /// If binding type is EnhancedInputAction, a reference to an enhanced input action + UPROPERTY(EditAnywhere, Category="InputImage") // can't be inside #if + TSoftObjectPtr InputAction; + + /// Where there are multiple mappings, which to prefer + UPROPERTY(EditAnywhere, Category="InputImage") + EInputImageDevicePreference DevicePreference = EInputImageDevicePreference::Auto; + + /// If BindingType is Key, the key + UPROPERTY(EditAnywhere, Category="InputImage") + FKey Key; + + /// When input mode changes, how quickly to update + UPROPERTY(EditAnywhere, Category="InputImage") + float UpdateDelay = 0.1f; + + /// Option to set this image as visible when bound, even if its state is hidden from outside + /// The default behaviour is that if this image is hidden by other means, visibility is not overridden. + /// This is safer but can result in a 1-frame blank image sometimes. Instead if you set this to true you can + /// set your images to hidden in the designer and have them display themselves when there's something to show. + /// You can then control invisiblity for other reasons from a parent widget. + UPROPERTY(EditAnywhere, Category="InputImage") + bool bOverrideHiddenState = false; + + /// The player index for which the input should be looked up + UPROPERTY(EditAnywhere, Category="InputImage") + int PlayerIndex = 0; + + /// Custom theme to use for this input image set; if not supplied will use UStevesGameSubsystem::DefaultUiTheme + UPROPERTY(EditAnywhere, Category="InputImage") + TObjectPtr CustomTheme; + + bool bSubbedToInputEvents = false; + bool bIsDirty = true; + float DelayUpdate = 0; + bool bHiddenBecauseBlank; + ESlateVisibility OldVisibility; + +public: + + /// Tell this image to display the bound action for the current input method + UFUNCTION(BlueprintCallable, Category="InputImage") + virtual void SetFromAction(FName Name); + + /// Tell this image to display the bound axis for the current input method + UFUNCTION(BlueprintCallable, Category="InputImage") + virtual void SetFromAxis(FName Name); + + /// Tell this image to display a specific key image + UFUNCTION(BlueprintCallable, Category="InputImage") + virtual void SetFromKey(FKey K); + + /// Tell this image to display Enhanced InputAction + UFUNCTION(BlueprintCallable, Category="InputImage") + virtual void SetFromInputAction(UInputAction* Action); + + /// Get the binding type that we'll use to populate the image + UFUNCTION(BlueprintCallable, Category="InputImage") + virtual EInputBindingType GetBindingType() const { return BindingType; } + + /// If BindingType is Action/Axis, get the name of the action or axis to look up the image for + UFUNCTION(BlueprintCallable, Category="InputImage") + virtual FName GetActionOrAxisName() const { return ActionOrAxisName; }; + + /// If BindingType is Key, get the key + UFUNCTION(BlueprintCallable, Category="InputImage") + virtual FKey GetKey() const { return Key; } + + /// Get the custom theme, if any + virtual UUiTheme* GetCustomTheme() const { return CustomTheme; } + /// Change the custom theme for this image + virtual void SetCustomTheme(UUiTheme* Theme); + + virtual void BeginDestroy() override; + + virtual void SetVisibility(ESlateVisibility InVisibility) override; + + // FTickableGameObject begin + virtual void Tick(float DeltaTime) override; + virtual TStatId GetStatId() const override; + virtual bool IsTickableWhenPaused() const override; + virtual bool IsTickableInEditor() const override; + virtual bool IsTickable() const override; + virtual ETickableTickType GetTickableTickType() const override; + // FTickableGameObject end + + virtual void FinishDestroy() override; + +protected: + + virtual TSharedRef RebuildWidget() override; + virtual void MarkImageDirty(); + virtual void UpdateImage(); + + UFUNCTION() + void OnInputModeChanged(int ChangedPlayerIdx, EInputMode InputMode); + UFUNCTION() + void OnEnhancedInputMappingsChanged(); + +}; diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/KeySprite.h b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/KeySprite.h new file mode 100644 index 00000000..62d8848d --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/KeySprite.h @@ -0,0 +1,35 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +#pragma once + +#include "CoreMinimal.h" + +#include "InputCoreTypes.h" +#include "Engine/DataTable.h" +#include "PaperSprite.h" +#include "KeySprite.generated.h" + +/// Struct for the rows of a DataTable which will hold the mapping from an FKey to a Paper Sprite which represents it +/// Used for on-screen prompts +USTRUCT(BlueprintType) +struct STEVESUEHELPERS_API FKeySprite : public FTableRowBase +{ + GENERATED_USTRUCT_BODY() + + // Import a DataTable using this struct by creating a CSV file like this: + // + // Name,Key,Sprite + // 1,Enter,"PaperSprite'/Game/Textures/UI/Frames/Keyboard_Black_Enter'" + // 2,SpaceBar,"PaperSprite'/Game/Textures/UI/Frames/Keyboard_Black_Space'" + // + // Key is just the latter part of EKeys::Name + // Sprite is the path to the Paper2D sprite (most likely from a shared sprite sheet) + +public: + + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="KeySprite") + FKey Key; + + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="KeySprite") + TObjectPtr Sprite = nullptr; +}; \ No newline at end of file diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/MenuBase.h b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/MenuBase.h new file mode 100644 index 00000000..4dee75e2 --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/MenuBase.h @@ -0,0 +1,129 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +#pragma once + +#include "CoreMinimal.h" + +#include "MenuStack.h" +#include "FocusablePanel.h" +#include "Blueprint/UserWidget.h" + +#include "MenuBase.generated.h" + +DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnMenuClosed, UMenuBase*, Menu, bool, bWasCancelled); + +/// This class is a type of focusable panel designed for menus or other dialogs. +/// It can be added to a UMenuStack to put it in context of a larger navigable group, +/// and if so represents one level in the chain of an assumed modal stack. Use UMenuStack::PushMenuByClass/Object +/// to add an entry of this type to the stack +/// If you use this class standalone instead without a stack, then you must call Open() on this instance to +/// make it add itself to the viewport. +UCLASS(Abstract, BlueprintType) +class STEVESUEHELPERS_API UMenuBase : public UFocusablePanel +{ + GENERATED_BODY() +public: + /// Raised just as the menu is closing + UPROPERTY(BlueprintAssignable, Category="Menu") + FOnMenuClosed OnClosed; + + /// Raised just after the menu has closed + UPROPERTY(BlueprintAssignable, Category="Menu") + FOnMenuClosed AfterClosed; + +protected: + UPROPERTY(BlueprintReadOnly, Category="Menu") + TWeakObjectPtr ParentStack; + + /// Whether this menu should request focus when it is displayed + /// The widget which is focussed will either be the InitialFocusWidget on newly displayed, or + /// the previously selected widget if regaining focus + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Focus") + bool bRequestFocus = true; + + /// Set this property to true if you want this menu to embed itself in the parent UMenuStack's MenuContainer + /// If false, this Menu will be added to the viewport independently and can float over other menus in the stack + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Layout") + bool bEmbedInParentContainer = true; + + /// Whether to hide this menu when it's superceded by another in the stack. + /// If you set this to "false" when bEmbedInParentContainer=true, then the superceding menu should have its + /// own bEmbedInParentContainer set to false in order to overlay on top of this one. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Behavior") + bool bHideWhenSuperceded = true; + + /// Whether this panel should block clicks itself (useful for preventing click-through). + /// Set to false if you want to be able to click through the panel to other elements. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Behavior") + bool bBlockClicks = true; + + /// How this menu should set the input mode when it becomes the top of the stack + /// This can be useful if your menus have variable input settings between levels in the stack + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Behavior") + EInputModeChange InputModeSetting = EInputModeChange::DoNotChange; + + /// How this menu should set the mouse pointer visibility when it becomes the top of the stack + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Behavior") + EMousePointerVisibilityChange MousePointerVisibility = EMousePointerVisibilityChange::DoNotChange; + + /// How this menu should set the game pause state when it becomes top of the stack + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Behavior") + EGamePauseChange GamePauseSetting = EGamePauseChange::DoNotChange; + + virtual void EmbedInParent(); + + UFUNCTION(BlueprintNativeEvent) + bool ValidateClose(bool bWasCancel); + + /// Called when this menu is superceded by another menu being pushed on to this stack + UFUNCTION(BlueprintNativeEvent) + void OnSupercededInStack(UMenuBase* ByMenu); + /// Called when this menu is superceded by another menu being pushed on to this stack + UFUNCTION(BlueprintNativeEvent) + void OnRegainedFocusInStack(); + + UFUNCTION(BlueprintNativeEvent) + void OnAddedToStack(UMenuStack* Parent); + UFUNCTION(BlueprintNativeEvent) + void OnRemovedFromStack(UMenuStack* Parent); + +public: + + /** + * @brief Open this menu. You should only call this if you're NOT using this in a UMenuStack, because the stack will + * call it for you when you add this menu to it + + * @param bIsRegainedFocus Set this to true if the reason this menu is opening is that it regained focus in a stack + */ + UFUNCTION(BlueprintCallable, Category="Menu") + void Open(bool bIsRegainedFocus = false); + /** + * @brief Request this menu to close. The menu can veto this request. + * @param bWasCancel Set this to true if the reason for closure was a cancellation action + * @return True if the request was approved + */ + UFUNCTION(BlueprintCallable, Category="Menu") + bool RequestClose(bool bWasCancel); + /** + * @brief Close this menu. This ALWAYS closes the menu, if you want it to be able to veto it, call RequestClose + * @param bWasCancel Set this to true if the reason for closure was a cancellation action + */ + UFUNCTION(BlueprintCallable, Category="Menu") + void Close(bool bWasCancel); + + + + TWeakObjectPtr GetParentStack() const { return ParentStack; } + virtual bool IsRequestingFocus_Implementation() const override { return bRequestFocus; } + + void AddedToStack(UMenuStack* Parent); + void RemovedFromStack(UMenuStack* Parent); + void SupercededInStack(UMenuBase* ByMenu); + void RegainedFocusInStack(); + void InputModeChanged(EInputMode OldMode, EInputMode NewMode); + + /// Return whether this menu is currently at the top of the menu stack + /// Note: if this menu is not owned by a stack, will always return true + UFUNCTION(BlueprintCallable, Category="Menu") + bool IsTopOfStack() const; +}; diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/MenuStack.h b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/MenuStack.h new file mode 100644 index 00000000..79fabd97 --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/MenuStack.h @@ -0,0 +1,163 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +#pragma once + +#include "CoreMinimal.h" + + +#include "FocusableInputInterceptorUserWidget.h" +#include "Framework/Application/IInputProcessor.h" +#include "StevesHelperCommon.h" + + +#include "MenuStack.generated.h" + +class UContentWidget; +class UMenuBase; + +DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnMenuStackClosed, class UMenuStack*, Stack, bool, bWasCancel); + +/// Represents a modal stack of menus which take focus and have a concept of "Back" +/// Each level within is a MenuBase, which must be "pushed" on to the stack. +/// Contained within MenuSystem (multiple menu stacks supported) +/// Has a Priority so that when multiple menu stacks are open, higher priority gets focus, +/// and when closed, next highest priority gets focus back. Focus is given when the first MenuBase is pushed onto the stack, +/// and given up when the last one is popped. +/// You can style this widget to be the general surrounds in which all MenuBase levels live inside +/// Create a Blueprint subclass of this and make sure you include a UContentWidget with the name +/// "MenuContainer" somewhere in the tree, which is where the menu contents will be placed. +UCLASS(Abstract, BlueprintType) +class STEVESUEHELPERS_API UMenuStack : public UFocusableInputInterceptorUserWidget +{ + GENERATED_BODY() + +protected: + EInputMode LastInputMode; + EInputModeChange PreviousInputMode; + EMousePointerVisibilityChange PreviousMouseVisibility; + EGamePauseChange PreviousPauseState; + + UPROPERTY() + TArray> Menus; + + virtual void BeforeFirstMenuOpened(); + virtual void FirstMenuOpened(); + virtual void LastMenuClosed(bool bWasCancel); + + virtual void NativeConstruct() override; + virtual void NativeDestruct() override; + + void SavePreviousInputMousePauseState(); + virtual void ApplyInputModeChange(EInputModeChange Change) const; + virtual void ApplyMousePointerVisibility(EMousePointerVisibilityChange Change) const; + virtual void ApplyGamePauseChange(EGamePauseChange Change) const; + + virtual bool HandleKeyDownEvent(const FKeyEvent& InKeyEvent) override; + UFUNCTION() + void InputModeChanged(int PlayerIndex, EInputMode NewMode); + + FDateTime TimeFirstOpen; + +public: + /// Input keys which go back a level in the menu stack (default Esc and B gamepad button) + /// Clear this list if you don't want this behaviour + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Input") + TArray BackKeys = TArray { EKeys::Escape, EKeys::Gamepad_FaceButton_Right }; + + /// Input keys which instantly close the menu stack (default gamepad start) + /// Clear this list if you don't want this behaviour + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Input") + TArray InstantCloseKeys = TArray { EKeys::Gamepad_Special_Right }; + + /// Whether to automatically add this stack to the viewport when a menu is pushed onto it, if not already added + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Viewport") + bool bAutoAddToViewport = true; + /// Whether to automatically remove this stack from the viewport when the last menu is removed + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Viewport") + bool bAutoRemoveFromViewport = true; + + /// This property will bind to a blueprint variable of the same name to contain the actual menu content + /// If not set, or the UiMenuBase is set to not use this container, levels are added independently to viewport + /// Use a NamedSlot for this most of the time, it gives you the most layout flexibility. + UPROPERTY(EditAnywhere, BlueprintReadOnly, meta = (BindWidget), Category="Menu") + TObjectPtr MenuContainer; + + /// Event raised when the stack is closed for any reason. If bWasCancel, the menu stack was closed because the + /// last item was cancelled. + UPROPERTY(BlueprintAssignable) + FOnMenuStackClosed OnClosed; + + /// How this stack should change the input mode when it opens (default no change) + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Behavior") + EInputModeChange InputModeSettingOnOpen = EInputModeChange::DoNotChange; + + /// When changing input mode, whether to flush pressed keys so we start fresh + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Behavior") + bool bFlushOnInputModeChange = false; + + /// How this stack should set the mouse pointer visibility when it opens (default no change) + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Behavior") + EMousePointerVisibilityChange MousePointerVisibilityOnOpen = EMousePointerVisibilityChange::DoNotChange; + + /// How this stack should set the game pause state when it opens (default no change) + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Behavior") + EGamePauseChange GamePauseSettingOnOpen = EGamePauseChange::DoNotChange; + + /// How this stack should change the input mode when it closes (default no change) + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Behavior") + EInputModeChange InputModeSettingOnClose = EInputModeChange::DoNotChange; + + /// How this stack should set the mouse pointer visibility when it closes (default no change) + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Behavior") + EMousePointerVisibilityChange MousePointerVisibilityOnClose = EMousePointerVisibilityChange::DoNotChange; + + /// How this stack should set the game pause state when it closes (default no change) + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Behavior") + EGamePauseChange GamePauseSettingOnClose = EGamePauseChange::DoNotChange; + + /// Minimum amount of time a menu should be open before responding to instant close key (prevent fast close because of leaked input) + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Behaviour") + float MinTimeOpen = 0.5f; + + /// Push a new menu level by class. This will instantiate the new menu, display it, and inform the previous menu that it's + /// been superceded. Use the returned instance if you want to cache it + UFUNCTION(BlueprintCallable, Category="Menu") + UMenuBase* PushMenuByClass(TSubclassOf MenuClass); + + /// Push a new menu level by instance on to the stack. This will display the new menu and inform the previous menu that it's + /// been superceded, which will most likely mean it will be hidden (but will retain its state) + UFUNCTION(BlueprintCallable, Category="Menu") + void PushMenuByObject(UMenuBase* NewMenu); + + /// Pop the top level of the menu stack. This *destroys* the top level menu, meaning it will lose all of its state. + /// You won't need to call this manually most of the time, because calling Close() on the MenuBase will do it. + UFUNCTION(BlueprintCallable, Category="Menu") + void PopMenu(bool bWasCancel); + + /// Get the number of active levels in the menu + UFUNCTION(BlueprintCallable, Category="Menu") + int Count() const { return Menus.Num(); } + + /// Close the entire stack at once. This does not give any of the menus chance to do anything before close, so if you + /// want them to do that, use PopMenu() until Count() == 0 instead + UFUNCTION(BlueprintCallable, Category="Menu") + void CloseAll(bool bWasCancel); + + /// Whether the top MenuBase on this stack is requesting focus + virtual bool IsRequestingFocus_Implementation() const override; + + virtual void SetFocusProperly_Implementation() override; + virtual void PopMenuIfTop(UMenuBase* UiMenuBase, bool bWasCancel); + virtual void RemoveFromParent() override; + + /// Return the menu which is currently top of the stack + UFUNCTION(BlueprintCallable, Category="Menu") + UMenuBase* GetTopMenu() const; + /// Return the menu which is currently right below the top of the stack + UFUNCTION(BlueprintCallable, Category="Menu") + UMenuBase* GetPreviousMenu() const; + + UMenuStack(); + + +}; diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/MultiSubtitleVerticalbox.h b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/MultiSubtitleVerticalbox.h new file mode 100644 index 00000000..4f0373ee --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/MultiSubtitleVerticalbox.h @@ -0,0 +1,101 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +#pragma once + +#include "CoreMinimal.h" +#include "Components/VerticalBox.h" +#include "Components/TextBlock.h" +#include "MultiSubtitleVerticalbox.generated.h" + + +/** + * A text block that can show multiple lines of subtitles. The subtitles are + * displayed in the text box with the most recent line first, and subtitles + * time out after a configurable time once they stop being emitted by the + * engine. + */ +UCLASS() +class STEVESUEHELPERS_API UMultiSubtitleVerticalbox : public UVerticalBox +{ + GENERATED_BODY() + +public: + /// The number of subtitles that may be shown at any given time. + UPROPERTY(EditAnywhere, BlueprintReadWrite, meta = (ClampMin = "1.0"), Category = "Subtitles") + uint8 ConcurrentLines = 1; + + /// The length of time in seconds subtitles should remain in the block for. + UPROPERTY(EditAnywhere, BlueprintReadWrite, meta = ( ClampMin = "1.0" ), Category = "Subtitles") + float MaximumAge = 4; + + /** The color of the text shown in child text boxes */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Textbox Appearance") + FSlateColor ColorAndOpacity = FLinearColor::White; + ; + + /** The minimum desired size for the text in child text boxes */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Textbox Appearance") + float MinDesiredWidth; + + /** The font to render the text with in child text boxes */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Textbox Appearance") + FSlateFontInfo Font; + + /** The brush to strike through text with in child text boxes */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Textbox Appearance") + FSlateBrush StrikeBrush; + + /** The direction the shadow is cast in child text boxes */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Textbox Appearance") + FVector2D ShadowOffset = FVector2D(1.0f, 1.0f); + + /** The color of the shadow in child text boxes */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Textbox Appearance", meta = (DisplayName = "Shadow Color")) + FLinearColor ShadowColorAndOpacity = FLinearColor::Transparent; + + virtual void BeginDestroy() override; + +protected: + bool bSubscribed; + + void SetSubtitleText(const FText& Text); + virtual TSharedRef RebuildWidget() override; + + struct FSubtitleHistory + { + FText Subtitle; + uint64 LastUpdate; + + // Less than operator needed by TArray<> to sort the subtitles in + // order of update. + const bool operator<(const FSubtitleHistory& rhs) const + { + // Use > here to sort in reverse. The or ensures stability if two + // subtitles are playing simultaneously + return !this -> Subtitle.IsEmpty() && (this->LastUpdate > rhs.LastUpdate || this->Subtitle.CompareTo(rhs.Subtitle) < 0); + } + }; + + /** Create a new TextBlock widget and add it to the vertical box. This will + * copy the 'Textbox Appearance' settings across into the new text block + * before adding it to the vertical box. + * + * @return A pointer to the newly created TextBlock, or nullptr on error + */ + TObjectPtr CreateTextBlock(); + + /** Update the subtitle history list to remove timed-out lines, and + * then set the contents of the text box to the new list of subtitles. + */ + void UpdateSubtitles(); + + // Handle for a timer that calls UpdateSubtitles() once per second + FTimerHandle SubtitleUpdateTimer; + + // The list of text blocks in the widget + UPROPERTY() + TArray> TextBlocks; + + // The list of subtitles to show + TArray Subtitles; +}; \ No newline at end of file diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/NamedOptionWidgetBase.h b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/NamedOptionWidgetBase.h new file mode 100644 index 00000000..eedd94ac --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/NamedOptionWidgetBase.h @@ -0,0 +1,62 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license + +#pragma once + +#include "CoreMinimal.h" +#include "OptionWidgetBase.h" +#include "NamedOptionWidgetBase.generated.h" + +/** + * Subclass of UOptionWidget base to provide convenient access via FNames, on top of the regular integers. + * Unlike UOptionWidgetBase, this widget can only be populated in code, you can't set up the options + * and the default selection in the designer. + */ +UCLASS() +class STEVESUEHELPERS_API UNamedOptionWidgetBase : public UOptionWidgetBase +{ + GENERATED_BODY() +protected: + TMap NameIndex; + FName SelectedName = NAME_None; + +public: + virtual void ClearOptions() override; + /** + * @brief Adds a new named option + * @param Name The name to identify the option by + * @param Option The text for the new option + * @return The index for the new option + */ + UFUNCTION(BlueprintCallable, Category="NamedOptionWidget") + virtual int AddNamedOption(FName Name, FText Option); + + + virtual void SetOptions(const TArray& Options, int NewSelectedIndex = 0) override; + /** + * @brief Sets all of the named options available for this control + * @param NewOptions All options to be available, indexed by name + * @param NewSelectedName Which of the options to select by default + */ + UFUNCTION(BlueprintCallable, Category="NamedOptionWidget") + virtual void SetNamedOptions(const TMap& NewOptions, FName NewSelectedName = NAME_None); + + UFUNCTION(BlueprintPure, Category="NamedOptionWidget") + virtual FName GetSelectedName() const; + + /// Get the index in this option list corresponding to a name + UFUNCTION(BlueprintCallable, Category="NamedOptionWidget") + virtual int GetIndexForName(FName Name); + + /// Get the name in this option list corresponding to an index + UFUNCTION(BlueprintCallable, Category="NamedOptionWidget") + virtual FName GetNameForIndex(int Index); + + virtual void SetSelectedIndex(int NewIndex) override; + /** + * @brief Change the selected option by name + * @param Name The name of the new option to set, can be None for no selection + */ + UFUNCTION(BlueprintCallable, Category="NamedOptionWidget") + virtual void SetSelectedName(FName Name); +}; diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/OptionWidgetBase.h b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/OptionWidgetBase.h new file mode 100644 index 00000000..3a5b459e --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/OptionWidgetBase.h @@ -0,0 +1,129 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +#pragma once + +#include "CoreMinimal.h" +#include "FocusableUserWidget.h" +#include "Styling/SlateTypes.h" +#include "StevesHelperCommon.h" +#include "OptionWidgetBase.generated.h" + +class UTextBlock; +class UImage; +class UButton; + +DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnSelectedOptionChanged, class UOptionWidgetBase*, Widget, int, NewIndex); + +UCLASS(Abstract, BlueprintType) +class STEVESUEHELPERS_API UOptionWidgetBase : public UFocusableUserWidget +{ + GENERATED_BODY() + + +public: + + // -- Properties automatically bound to Blueprint widget + UPROPERTY(EditAnywhere, BlueprintReadOnly, meta = (BindWidget), Category="OptionWidget") + TObjectPtr MouseVersion; + + UPROPERTY(EditAnywhere, BlueprintReadOnly, meta = (BindWidget), Category="OptionWidget") + TObjectPtr MouseUpButton; + + UPROPERTY(EditAnywhere, BlueprintReadOnly, meta = (BindWidget), Category="OptionWidget") + TObjectPtr MouseDownButton; + + UPROPERTY(EditAnywhere, BlueprintReadOnly, meta = (BindWidget), Category="OptionWidget") + TObjectPtr MouseUpImage; + + UPROPERTY(EditAnywhere, BlueprintReadOnly, meta = (BindWidget), Category="OptionWidget") + TObjectPtr MouseDownImage; + + UPROPERTY(EditAnywhere, BlueprintReadOnly, meta = (BindWidget), Category="OptionWidget") + TObjectPtr MouseText; + + UPROPERTY(EditAnywhere, BlueprintReadOnly, meta = (BindWidget), Category="OptionWidget") + TObjectPtr GamepadVersion; + + UPROPERTY(EditAnywhere, BlueprintReadOnly, meta = (BindWidget), Category="OptionWidget") + TObjectPtr GamepadUpImage; + + UPROPERTY(EditAnywhere, BlueprintReadOnly, meta = (BindWidget), Category="OptionWidget") + TObjectPtr GamepadDownImage; + + UPROPERTY(EditAnywhere, BlueprintReadOnly, meta = (BindWidget), Category="OptionWidget") + TObjectPtr GamepadText; + + /// Event raised when the selected option changes + UPROPERTY(BlueprintAssignable) + FOnSelectedOptionChanged OnSelectedOptionChanged; + + UFUNCTION(BlueprintCallable, Category="OptionWidget") + /// Remove all options + virtual void ClearOptions(); + + /** + * @brief Adds a new option + * @param Option The text for the new option + * @return The index for the new option + */ + UFUNCTION(BlueprintCallable, Category="OptionWidget") + virtual int AddOption(FText Option); + + + /** + * @brief Sets all of the options available for this control + * @param Options All options to be available + * @param NewSelectedIndex Which of the options to select by default + */ + UFUNCTION(BlueprintCallable, Category="OptionWidget") + virtual void SetOptions(const TArray& Options, int NewSelectedIndex = 0); + + UFUNCTION(BlueprintPure, Category="OptionWidget") + virtual int GetSelectedIndex() const { return SelectedIndex; } + + UFUNCTION(BlueprintPure, Category="OptionWidget") + virtual FText GetSelectedOption() const; + /** + * @brief Change the selected index option + * @param NewIndex The new index to set, can be -1 for no selection + */ + UFUNCTION(BlueprintCallable, Category="OptionWidget") + virtual void SetSelectedIndex(int NewIndex); + + virtual void SetFocusProperly_Implementation() override; + + +protected: + + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category=Content) + TArray Options; + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category=Content) + int SelectedIndex; + + UFUNCTION(BlueprintCallable, Category="OptionWidget") + virtual void SetMouseMode(); + UFUNCTION(BlueprintCallable, Category="OptionWidget") + virtual void SetButtonMode(); + + UFUNCTION(BlueprintCallable, Category="OptionWidget") + virtual void UpdateFromInputMode(EInputMode Mode); + virtual void NativeConstruct() override; + virtual void NativeDestruct() override; + UFUNCTION(BlueprintCallable, Category="OptionWidget") + virtual void ChangeOption(int Delta); + UFUNCTION(BlueprintCallable, Category="OptionWidget") + virtual EInputMode GetCurrentInputMode() const; + virtual void UpdateUpDownButtons(); + + + UFUNCTION(BlueprintImplementableEvent, Category="OptionWidget") + void PostSelectedOptionChanged(); + +protected: + UFUNCTION() + void InputModeChanged(int PlayerIndex, EInputMode NewMode); + UFUNCTION() + void MouseUpClicked(); + UFUNCTION() + void MouseDownClicked(); +}; diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/RichTextBlockInputImageDecorator.h b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/RichTextBlockInputImageDecorator.h new file mode 100644 index 00000000..3f60a998 --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/RichTextBlockInputImageDecorator.h @@ -0,0 +1,21 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +#pragma once + +#include "CoreMinimal.h" +#include "Components/RichTextBlockDecorator.h" + +#include "RichTextBlockInputImageDecorator.generated.h" + +class UUiTheme; + +UCLASS() +class STEVESUEHELPERS_API URichTextBlockInputImageDecorator : public URichTextBlockDecorator +{ + GENERATED_BODY() + +public: + + virtual TSharedPtr CreateDecorator(URichTextBlock* InOwner) override; + +}; diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/SmoothChangingProgressBar.h b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/SmoothChangingProgressBar.h new file mode 100644 index 00000000..7755ea56 --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/SmoothChangingProgressBar.h @@ -0,0 +1,55 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license + +#pragma once + +#include "CoreMinimal.h" +#include "Components/ProgressBar.h" +#include "SmoothChangingProgressBar.generated.h" + +/** + * A specialised progress bar that can be told to change its percent smoothly instead of all at once. + * Note: Because SetPercent isn't virtual on UProgressBar, you need to use the alternate SetPercentSmoothly + * function instead, and call StopSmoothPercentChange to interrupt it if you need to manually set it using + * SetPercent. + */ +UCLASS() +class STEVESUEHELPERS_API USmoothChangingProgressBar : public UProgressBar +{ + GENERATED_BODY() + +protected: + TWeakPtr SmoothChangeHandle; + + float TargetPercent; + + void UnregisterTimer(); + EActiveTimerReturnType TickPercent(double CurrTime, float DeltaTime); +public: + /// The speed at which the progress bar changes. This value means the max percentage changes + /// in one second. Set this to 0 to make changes instant. Changes to this value only affect + /// the next call to SetPercentSmoothly. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Progress") + float PercentChangeSpeed = 1.0f; + + /// The frequency at which we should update the bar. Set this to 0 to update every frame, + /// or > 0 to update every X seconds (useful to save tick time for slow updates). + /// Changes to this value only affect the next call to SetPercentSmoothly. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Progress") + float PercentChangeFrequency = 0.0f; + + /// Changes the bar percentage smoothly from its current value. + /// Automatically interrupts any existing smooth change. + UFUNCTION(BlueprintCallable, Category="Progress") + void SetPercentSmoothly(float InPercent); + + /// Stop any pending smooth changes to percent + /// Call this if you need to interrupt any current smooth change and + UFUNCTION(BlueprintCallable, Category="Progress") + void StopSmoothPercentChange(); + + virtual void BeginDestroy() override; + + + +}; diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/StevesSubtitleTextblock.h b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/StevesSubtitleTextblock.h new file mode 100644 index 00000000..af15bd62 --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/StevesSubtitleTextblock.h @@ -0,0 +1,28 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license + +#pragma once + +#include "CoreMinimal.h" +#include "Components/TextBlock.h" +#include "StevesSubtitleTextblock.generated.h" + +/** + * A simple Text Block that automatically displays subtitle text. Just drop it in and style it + * how you want, subtitles will appear here when played, and the default canvas-rendered crappy + * versions will be disabled. + */ +UCLASS() +class STEVESUEHELPERS_API UStevesSubtitleTextblock : public UTextBlock +{ + GENERATED_BODY() + +public: + virtual void BeginDestroy() override; + +protected: + bool bSubscribed; + + void SetSubtitleText(const FText& Text); + virtual TSharedRef RebuildWidget() override; +}; diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/TabButton.h b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/TabButton.h new file mode 100644 index 00000000..72870112 --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/TabButton.h @@ -0,0 +1,47 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +#pragma once + +#include "CoreMinimal.h" +#include "Blueprint/UserWidget.h" +#include "TabButton.generated.h" + +DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnTabSelected,int,TabIndex); + +/** + * Tab button - widget that is instantiated on a tab list + */ +UCLASS() +class STEVESUEHELPERS_API UTabButton : public UUserWidget +{ + GENERATED_BODY() + + +public: + + /// + + UPROPERTY(BlueprintAssignable,BlueprintCallable, Category="TabButton") + FOnTabSelected OnTabSelected; + + UPROPERTY(BlueprintReadOnly, Category="TabButton") + int TabIndex; + + UPROPERTY(BlueprintReadOnly, Category="TabButton") + bool bTabSelected=false; + + void Initialise(int InTabIndex,const FText& InLabel); + + UFUNCTION(BlueprintNativeEvent, Category="TabButton") + void SetTabLabel(const FText& InLabel); + + UFUNCTION(BlueprintCallable, Category="TabButton") + void SetTabSelected(bool bSelected); + + UFUNCTION(BlueprintCallable, Category="TabButton") + void NotifyTabSelected(); + + UFUNCTION(BlueprintNativeEvent, Category="TabButton") + void RefreshForSelectionChanged(); + +}; diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/TabListWidget.h b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/TabListWidget.h new file mode 100644 index 00000000..e3c85180 --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/TabListWidget.h @@ -0,0 +1,112 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +#pragma once + +#include "CoreMinimal.h" +#include "StevesUI/FocusableInputInterceptorUserWidget.h" +#include "TabListWidget.generated.h" + +DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnContentWidgetChanged,UWidget*,PreviousContentWidget,UWidget*,NewContentWidget); + +class UWidgetSwitcher; +class UTabButton; +/** Information about a registered tab in the tab list */ +USTRUCT() +struct FTabListRegisteredTabInfo +{ + GENERATED_BODY() + +public: + /** The index of this tab */ + UPROPERTY() + int32 TabIndex; + + /** The button for this tab */ + UPROPERTY() + TObjectPtr TabButton; + + /** The content to be activated. */ + UPROPERTY() + TObjectPtr ContentInstance; + + FTabListRegisteredTabInfo() + : TabIndex(INDEX_NONE) + , TabButton(nullptr) + , ContentInstance(nullptr) + {} +}; + + +/** + * Tab list widget - provides a tab selection bar that can control a widget switcher + */ +UCLASS() +class STEVESUEHELPERS_API UTabListWidget : public UFocusableInputInterceptorUserWidget +{ + GENERATED_BODY() +public: + + + /// Event called when tab content has changed + UPROPERTY(BlueprintCallable,BlueprintAssignable) + FOnContentWidgetChanged OnContentWidgetChanged; + + /// Input keys which navigate to the previous tab (left) + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Input") + TArray PreviousTabKeys = TArray { EKeys::Gamepad_LeftShoulder, EKeys::Q }; + + /// Input keys which navigate to the next tab (right) + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Input") + TArray NextTabKeys = TArray { EKeys::Gamepad_RightShoulder, EKeys::E }; + + /// Map of tabs, buttons, content + TMap RegisteredTabs; + + // Where the tab buttons will be added + UPROPERTY(BlueprintReadWrite, meta = (BindWidget), Category="TabButton") + TObjectPtr TabButtonContainer; + + UPROPERTY(BlueprintReadOnly, Category="TabButton") + int SelectedTabIndex=-1; + + UPROPERTY(EditAnywhere,BlueprintReadWrite, Category="TabButton") + FMargin TabButtonPadding=FMargin(10,0); + + UPROPERTY(BlueprintReadOnly, Category="TabButton") + TObjectPtr TargetWidgetSwitcher; + + /** + * Registers tab content with this tab widget + * @param TabID The ID to use for this widget + * @param ButtonWidgetType The class of button to use + * @param ContentWidget The widget to activate when pressed + * @return True If successful + */ + UFUNCTION(BlueprintCallable, Category="TabButton") + bool RegisterTab(FName TabID, TSubclassOf ButtonWidgetType,FText Label, UWidget* ContentWidget); + + /** + * Called when a given tab button has been pressed + * @param TabIndex + */ + UFUNCTION() + void TabPressed(int TabIndex); + + /** + * Assign a widget switcher to be driven by this tab selection. + * @param WidgetSwitcher + */ + UFUNCTION(BlueprintCallable, Category="TabButton") + void SetTargetWidgetSwitcher(UWidgetSwitcher* WidgetSwitcher); + + UFUNCTION(BlueprintCallable, Category="TabButton") + void IncrementTabIndex(int Amount); + + UFUNCTION(BlueprintCallable, Category="TabButton") + void SelectTab(int TabIndex); + + virtual bool HandleKeyDownEvent(const FKeyEvent& InKeyEvent) override; + +private: + bool GetTabDataForIndex(int Index,FTabListRegisteredTabInfo& TabInfo); +}; diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/TypewriterTextWidget.h b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/TypewriterTextWidget.h new file mode 100644 index 00000000..77b500c3 --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/TypewriterTextWidget.h @@ -0,0 +1,243 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +// Original Copyright (c) Sam Bloomberg https://github.com/redxdev/UnrealRichTextDialogueBox (MIT License) +// Updates: +// 1. Fixed adding a spurious newline to single-line text +// 2. Expose line finished as event +// 3. Changed names of classes to indicate functionality better (typewriter not dialogue) +// 4. Update minimum desired size on calculate so that flexi boxes can start at the correct size +// instead of growing when the newline is added + +#pragma once + +#include "CoreMinimal.h" +#include "Blueprint/UserWidget.h" +#include "Components/RichTextBlock.h" +#include "Framework/Text/RichTextLayoutMarshaller.h" +#include "Framework/Text/SlateTextLayout.h" +#include "TypewriterTextWidget.generated.h" + + +DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnTypewriterLineFinished, class UTypewriterTextWidget*, Widget); +DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnTypewriterLetterAdded, class UTypewriterTextWidget*, Widget, const FString&, Char); +DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnTypewriterStartWord, class UTypewriterTextWidget*, Widget, const FString&, Word); +DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnTypewriterRunNameChanged, class UTypewriterTextWidget*, Widget, const FString&, NewRunName); +/** + * A text block that exposes more information about text layout for typewriter widget. + */ +UCLASS() +class URichTextBlockForTypewriter : public URichTextBlock +{ + GENERATED_BODY() + +public: + FORCEINLINE TSharedPtr GetTextLayout() const + { + return TextLayout; + } + + FORCEINLINE TSharedPtr GetTextMarshaller() const + { + return TextMarshaller; + } + + virtual void ReleaseSlateResources(bool bReleaseChildren) override; + +protected: + virtual TSharedRef RebuildWidget() override; + +private: + TSharedPtr TextLayout; + TSharedPtr TextMarshaller; +}; + +UCLASS(Blueprintable) +class STEVESUEHELPERS_API UTypewriterTextWidget : public UUserWidget +{ + GENERATED_BODY() + +public: + UTypewriterTextWidget(const FObjectInitializer& ObjectInitializer); + void ClearLetterCountdownTimer(); + + /// Event called when a line has finished playing, whether on its own or when skipped to end + UPROPERTY(BlueprintAssignable) + FOnTypewriterLineFinished OnTypewriterLineFinished; + + /// Event called when one or more new letters have been displayed + UPROPERTY(BlueprintAssignable) + FOnTypewriterLetterAdded OnTypewriterLetterAdded; + + /// Event called when a new word has started + UPROPERTY(BlueprintAssignable) + FOnTypewriterStartWord OnTypewriterStartWord; + + /// Event called when the "run name" of the text changes aka the rich text style markup. Also called when reverts to default. + UPROPERTY(BlueprintAssignable) + FOnTypewriterRunNameChanged OnTypewriterRunNameChanged; + + UPROPERTY(BlueprintReadOnly, meta = (BindWidget), Category = "Typewriter") + TObjectPtr LineText; + + /// The amount of time between printing individual letters (for the "typewriter" effect). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Typewriter") + float LetterPlayTime = 0.025f; + + /// The amount of time to wait after finishing the line before actually marking it completed. + /// This helps prevent accidentally progressing dialogue on short lines. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Typewriter") + float EndHoldTime = 0.15f; + + /// How long to pause at sentence terminators ('.', '!', '?') before proceeding (ignored if at end of text) + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Typewriter") + float PauseTimeAtSentenceTerminators = 0.5f; + + /// Characters which will terminate a sentence, which will potentially pause + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Typewriter") + FString SentenceTerminators = ".!?"; + + /// Characters which terminate a clause, which is a preferred place to split an overly long line + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Typewriter") + FString ClauseTerminators = ",;:"; + + /// If true (default), only pauses at sentence terminators if there's whitespace following them. + /// This prevents pausing on strings like ".txt" + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Typewriter") + bool bPauseOnlyIfWhitespaceFollowsSentenceTerminator = true; + + /// If set > 0, splits a single PlayLine into multiple segments of this number of lines maximum + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Typewriter") + int MaxNumberOfLines = 0; + + /// If set to true, the text will continue to be played when paused + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Typewriter") + bool bPlayWhenPaused = false; + + /// If set to true, OnTypewriterStartWord will be called when a new word starts + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Typewriter") + bool bNewWordEvent = false; + + /// Set Text immediately + UFUNCTION(BlueprintCallable, Category = "Typewriter") + void SetText(const FText& InText); + + /// Set Text immediately + UFUNCTION(BlueprintCallable, Category = "Typewriter") + FText GetText() const; + + + /** + * Play a line of text. + * Note: if, when line splits are calculated, this line exceeds MaxNumberOfLines, then only this number of lines + * will be played by this call. In that case, HasMoreLineParts() will return true, and you will need to call + * PlayNextLinePart() to play the remainder of the line. + * @param InLine The input line + * @param Speed + */ + UFUNCTION(BlueprintCallable, Category = "Typewriter") + void PlayLine(const FText& InLine, float Speed = 1.0f); + + UFUNCTION(BlueprintCallable, Category = "Typewriter") + void GetCurrentLine(FText& OutLine) const { OutLine = CurrentLine; } + + /// Return whether the entire line has finished playing + UFUNCTION(BlueprintCallable, Category = "Typewriter") + bool HasFinishedPlayingLine() const { return bHasFinishedPlaying; } + + /// Returns whether the number of lines exceeded MaxNumberOfLines and there are still parts to play. + UFUNCTION(BlueprintCallable, Category = "Typewriter") + bool HasMoreLineParts() const { return bHasMoreLineParts; } + + /// If HasMoreLineParts() is true, play the next part of the line originally requested by PlayLine + UFUNCTION(BlueprintCallable, Category = "Typewriter") + void PlayNextLinePart(float Speed = 1.0f); + + UFUNCTION(BlueprintCallable, Category = "Typewriter") + void SkipToLineEnd(); + + /// Get the name of the current rich text run, if any + const FString& GetCurrentRunName() const { return CurrentRunName; } + + /// Get typewriter play speed + UFUNCTION(BlueprintCallable, Category = "Typewriter") + float GetPlaySpeed() const {return CurrentPlaySpeed;} + + /// Set typewriter play speed + UFUNCTION(BlueprintCallable, Category = "Typewriter") + void SetPlaySpeed(float Speed) {CurrentPlaySpeed = Speed;} + + UFUNCTION(BlueprintCallable, Category = "Typewriter") + void FindWordVowels(const FString& Word, TArray& VowelsPos); + + virtual void NativeTick(const FGeometry& MyGeometry, float InDeltaTime) override; + +protected: + virtual void NativeConstruct() override; + + /// Called when on or more letters are added, if subclasses want to override + UFUNCTION(BlueprintImplementableEvent, Category = "Typewriter") + void OnPlayLetter(); + + UFUNCTION(BlueprintImplementableEvent, Category = "Typewriter") + void OnLineFinishedPlaying(); + + int OnStartNewWord(const FString SegmentRemain); + + /// Name of the current rich text run, if any + /// Can be used to identify the style of the most recently played letter + UPROPERTY(BlueprintReadOnly, Category = "Typewriter") + FString CurrentRunName; + + +private: + void PlayNextLetter(); + bool IsSentenceTerminator(TCHAR Letter) const; + bool IsClauseTerminator(TCHAR Letter) const; + bool IsPunctuation(TCHAR Letter) const; + int FindLastTerminator(const FString& CurrentLineString, int Count) const; + + int CalculateMaxLength(); + void CalculateWrappedString(const FString& CurrentLineString); + FString CalculateSegments(FString* OutCurrentRunName); + void StartPlayLine(); + + UPROPERTY() + FText CurrentLine; + + FString RemainingLinePart; + + struct FTypewriterTextSegment + { + FString Text; + FRunInfo RunInfo; + }; + TArray Segments; + + // The section of the text that's already been printed out and won't ever change. + // This lets us cache some of the work we've already done. We can't cache absolutely + // everything as the last few characters of a string may change if they're related to + // a named run that hasn't been completed yet. + FString CachedSegmentText; + int32 CachedLetterIndex = 0; + + int32 CurrentSegmentIndex = 0; + int32 CurrentLetterIndex = 0; + int32 MaxLetterIndex = 0; + int32 NumberOfLines = 0; + int32 NextBlankLetterLeft = -1; + float CombinedTextHeight = 0; + + uint32 bHasFinishedPlaying : 1; + uint32 bHasMoreLineParts : 1; + + // Properties related to animation + float NextLetterCountdown = 0; + float NextLetterCountdownInterval = 0; + float StartPlayLineCountdown = 0; + float SkipToLineEndCountdown = 0; + + float CurrentPlaySpeed = 1; + float PauseTime = 0; + bool bFirstPlayLine = true; + bool bLastSegmentEndsWithBlank = false; +}; \ No newline at end of file diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/UiTheme.h b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/UiTheme.h new file mode 100644 index 00000000..e53a10b7 --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUI/UiTheme.h @@ -0,0 +1,27 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +#pragma once + +#include "CoreMinimal.h" +#include "Engine/DataAsset.h" +#include "Engine/DataTable.h" + + +#include "UiTheme.generated.h" + +/// Custom asset to conveniently hold theme information for the UI +/// Currently only lightly used to provide simple access to button images, but I intend to use +/// this more extensively later +UCLASS(Blueprintable) +class STEVESUEHELPERS_API UUiTheme : public UPrimaryDataAsset +{ + GENERATED_BODY() + +public: + + UPROPERTY(EditDefaultsOnly, Category="StevesUI") + TSoftObjectPtr KeyboardMouseImages; + UPROPERTY(EditDefaultsOnly, Category="StevesUI") + TSoftObjectPtr XboxControllerImages; + +}; diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUiHelpers.cpp b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUiHelpers.cpp new file mode 100644 index 00000000..99e4d0dc --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUiHelpers.cpp @@ -0,0 +1,89 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +#include "StevesUiHelpers.h" + +#include "Components/HorizontalBoxSlot.h" +#include "Components/PanelWidget.h" +#include "Components/VerticalBoxSlot.h" + +UPanelSlot* StevesUiHelpers::InsertChildWidgetAt(UPanelWidget* Parent, UWidget* Child, int AtIndex) +{ + checkf(AtIndex <= Parent->GetChildrenCount(), TEXT("Insertion index %d is greater than child count"), AtIndex); + + // Short-circuit for simple case of insert at end + const int OrigCount = Parent->GetChildrenCount(); + if (OrigCount == AtIndex) + { + return Parent->AddChild(Child); + } + else + { + // Note: cannot use InsertChildAt, that's editor-only + // There are a few options with the runtime API + // 1. Remove all children and re-add them in the right order + // 2. Remove all children on or after the insert point and re-add those after adding the new item + // 3. Use ReplaceChildAt to insert a new item and move everything down, with AddChild at the end + // + // I'm going with 2. because that seems to blend best performance with the most well defined behaviour + // I'm not sure ReplaceChildAt is designed to move children around (note: ShiftChild exists but again, editor-only) + + TArray WidgetsToReplaceReversed; + TArray HVBoxPadding; + + // Keep slot padding info + HVBoxPadding.Reserve(OrigCount - AtIndex); + // Go backwards for consistency with below + const auto Slots = Parent->GetSlots(); + for (int i = OrigCount - 1; i >= AtIndex; --i) + { + if (Slots.IsValidIndex(i)) + { + const auto Slot = Slots[i]; + if (const auto HSlot = Cast(Slot)) + { + HVBoxPadding.Add(HSlot->GetPadding()); + } + else if (const auto VSlot = Cast(Slot)) + { + HVBoxPadding.Add(VSlot->GetPadding()); + } + else + { + HVBoxPadding.Add(FMargin()); + } + } + } + + WidgetsToReplaceReversed.Reserve(OrigCount - AtIndex); + // Go backwards so we can remove as we go and not remove from the middle + for (int i = OrigCount - 1; i >= AtIndex; --i) + { + WidgetsToReplaceReversed.Add(Parent->GetChildAt(i)); + Parent->RemoveChildAt(i); + } + // insert item + auto Slot = Parent->AddChild(Child); + // add back previous, reverse order + for (int i = WidgetsToReplaceReversed.Num() - 1; i >= 0; --i) + { + auto PrevSlot = Parent->AddChild(WidgetsToReplaceReversed[i]); + + // Restore the padding on H/VBox slots + if (HVBoxPadding.IsValidIndex(i)) + { + if (auto HSlot = Cast(PrevSlot)) + { + HSlot->SetPadding(HVBoxPadding[i]); + } + else if (auto VSlot = Cast(PrevSlot)) + { + VSlot->SetPadding(HVBoxPadding[i]); + } + } + + } + return Slot; + + } + +} diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUiHelpers.h b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUiHelpers.h new file mode 100644 index 00000000..90266769 --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesUiHelpers.h @@ -0,0 +1,20 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +#pragma once +#include "CoreMinimal.h" +#include "Components/Widget.h" + +/// Helper Ui routines that UE4 is missing, all static +/// Exposed to BP in in StevesBPL +class STEVESUEHELPERS_API StevesUiHelpers +{ +public: + /** + * Insert a child widget at a specific index + * @param Parent The container widget + * @param Child The child widget to add + * @param AtIndex The index at which the new child should exist + * @returns The slot the child was inserted at + */ + static UPanelSlot* InsertChildWidgetAt(UPanelWidget* Parent, UWidget* Child, int AtIndex = 0); +}; diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesVisualLogger.h b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesVisualLogger.h new file mode 100644 index 00000000..6dcf045d --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/Public/StevesVisualLogger.h @@ -0,0 +1,125 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +#pragma once +#include "CoreMinimal.h" +#include "VisualLogger/VisualLogger.h" + +class STEVESUEHELPERS_API FStevesVisualLogger +{ +protected: + static void InternalPolyLogfImpl(const UObject* LogOwner, + const FLogCategoryBase& Category, + ELogVerbosity::Type Verbosity, + const TArray& Points, + const FColor& Color, + const uint16 Thickness); + + + static void InternalPolyLogf(const UObject* LogOwner, + const FLogCategoryBase& Category, + ELogVerbosity::Type Verbosity, + const FVector& Center, + const FVector& UpAxis, + const float OuterRadius, + const float InnerRadius, + const int NumPoints, + const FColor& Color, + const uint16 Thickness) + { + + const bool bIsStar = !FMath::IsNearlyEqual(OuterRadius, InnerRadius); + + const FQuat Rotation = FQuat::FindBetweenNormals(FVector::UpVector, UpAxis); + const FVector YAxis = Rotation.RotateVector(FVector::YAxisVector); + + const int Count = bIsStar ? NumPoints * 2 : NumPoints; + const float RotInc = UE_TWO_PI / Count; + TArray Vertices; + // Start + Vertices.Add(Center + YAxis * OuterRadius); + for (int i = 1; i < Count; ++i) + { + const float Scale = bIsStar && (i % 2) != 0 ? InnerRadius : OuterRadius; + const FQuat Rot = FQuat(UpAxis, RotInc * i); + const FVector V = Center + Rot.RotateVector(YAxis * Scale); + Vertices.Add(V); + } + // End + Vertices.Add(Center + YAxis * OuterRadius); + + InternalPolyLogfImpl(LogOwner, Category, Verbosity, Vertices, Color, Thickness); + } + +public: + static void TriangleLogf(const UObject* LogOwner, const FLogCategoryBase& Category, ELogVerbosity::Type Verbosity, const FVector& Center, const FVector& UpAxis, const float Radius, const FColor& Color, const uint16 Thickness) + { + InternalPolyLogf(LogOwner, Category, Verbosity, Center, UpAxis, Radius, Radius, 3, Color, Thickness); + } + + static void SquareLogf(const UObject* LogOwner, const FLogCategoryBase& Category, ELogVerbosity::Type Verbosity, const FVector& Center, const FVector& UpAxis, const float Radius, const FColor& Color, const uint16 Thickness) + { + InternalPolyLogf(LogOwner, Category, Verbosity, Center, UpAxis, Radius, Radius, 4, Color, Thickness); + } + + static void PolyLogf(const UObject* LogOwner, const FLogCategoryBase& Category, ELogVerbosity::Type Verbosity, const FVector& Center, const FVector& UpAxis, const float Radius, const int NumPoints, const FColor& Color, const uint16 Thickness) + { + InternalPolyLogf(LogOwner, Category, Verbosity, Center, UpAxis, Radius, Radius, NumPoints, Color, Thickness); + } + + static void StarLogf(const UObject* LogOwner, const FLogCategoryBase& Category, ELogVerbosity::Type Verbosity, const FVector& Center, const FVector& UpAxis, const float OuterRadius, const float InnerRadius, const int NumPoints, const FColor& Color, const uint16 Thickness) + { + InternalPolyLogf(LogOwner, Category, Verbosity, Center, UpAxis, OuterRadius, InnerRadius, NumPoints, Color, Thickness); + } +}; + +#if ENABLE_VISUAL_LOG + +// 2D Triangle shape +#define UE_VLOG_TRIANGLE(LogOwner, CategoryName, Verbosity, Center, UpAxis, Radius, Color) if(FVisualLogger::IsRecording()) FStevesVisualLogger::TriangleLogf(LogOwner, CategoryName, ELogVerbosity::Verbosity, Center, UpAxis, Radius, Color, 0) +#define UE_CVLOG_TRIANGLE(Condition, LogOwner, CategoryName, Verbosity, Center, UpAxis, Radius, Color) if(FVisualLogger::IsRecording() && Condition) {UE_VLOG_TRIANGLE(LogOwner, CategoryName, Verbosity, Center, UpAxis, Radius, Color, 0);} +#define UE_VLOG_TRIANGLE_THICK(LogOwner, CategoryName, Verbosity, Center, UpAxis, Radius, Color, Thickness) if(FVisualLogger::IsRecording()) FStevesVisualLogger::TriangleLogf(LogOwner, CategoryName, ELogVerbosity::Verbosity, Center, UpAxis, Radius, Color, Thickness) +#define UE_CVLOG_TRIANGLE_THICK(Condition, LogOwner, CategoryName, Verbosity, Center, UpAxis, Radius, Color, Thickness) if(FVisualLogger::IsRecording() && Condition) {UE_VLOG_TRIANGLE_THICK(LogOwner, CategoryName, Verbosity, Center, UpAxis, Radius, Color, Thickness);} + +// 2D Square shape +#define UE_VLOG_SQUARE(LogOwner, CategoryName, Verbosity, Center, UpAxis, Radius, Color) if(FVisualLogger::IsRecording()) FStevesVisualLogger::SquareLogf(LogOwner, CategoryName, ELogVerbosity::Verbosity, Center, UpAxis, Radius, Color, 0) +#define UE_CVLOG_SQUARE(Condition, LogOwner, CategoryName, Verbosity, Center, UpAxis, Radius, Color) if(FVisualLogger::IsRecording() && Condition) {UE_VLOG_SQUARE(LogOwner, CategoryName, Verbosity, Center, UpAxis, Radius, Color, 0);} +#define UE_VLOG_SQUARE_THICK(LogOwner, CategoryName, Verbosity, Center, UpAxis, Radius, Color, Thickness) if(FVisualLogger::IsRecording()) FStevesVisualLogger::SquareLogf(LogOwner, CategoryName, ELogVerbosity::Verbosity, Center, UpAxis, Radius, Color, Thickness) +#define UE_CVLOG_SQUARE_THICK(Condition, LogOwner, CategoryName, Verbosity, Center, UpAxis, Radius, Color, Thickness) if(FVisualLogger::IsRecording() && Condition) {UE_VLOG_SQUARE_THICK(LogOwner, CategoryName, Verbosity, Center, UpAxis, Radius, Color, Thickness);} + +// 2D poly shape +#define UE_VLOG_POLY(LogOwner, CategoryName, Verbosity, Center, UpAxis, Radius, NumPoints, Color) if(FVisualLogger::IsRecording()) FStevesVisualLogger::PolyLogf(LogOwner, CategoryName, ELogVerbosity::Verbosity, Center, UpAxis, Radius, NumPoints, Color, 0) +#define UE_CVLOG_POLY(Condition, LogOwner, CategoryName, Verbosity, Center, UpAxis, Radius, NumPoints, Color) if(FVisualLogger::IsRecording() && Condition) {UE_VLOG_POLY(LogOwner, CategoryName, Verbosity, Center, UpAxis, Radius, NumPoints, Color, 0);} +#define UE_VLOG_POLY_THICK(LogOwner, CategoryName, Verbosity, Center, UpAxis, Radius, NumPoints, Color, Thickness) if(FVisualLogger::IsRecording()) FStevesVisualLogger::PolyLogf(LogOwner, CategoryName, ELogVerbosity::Verbosity, Center, UpAxis, Radius, NumPoints, Color, Thickness) +#define UE_CVLOG_POLY_THICK(Condition, LogOwner, CategoryName, Verbosity, Center, UpAxis, Radius, NumPoints, Color, Thickness) if(FVisualLogger::IsRecording() && Condition) {UE_VLOG_POLY_THICK(LogOwner, CategoryName, Verbosity, Center, UpAxis, Radius, NumPoints, Color, Thickness);} + +// 2D star shape +#define UE_VLOG_STAR(LogOwner, CategoryName, Verbosity, Center, UpAxis, OuterRadius, InnerRadius, NumPoints, Color) if(FVisualLogger::IsRecording()) FStevesVisualLogger::StarLogf(LogOwner, CategoryName, ELogVerbosity::Verbosity, Center, UpAxis, OuterRadius, InnerRadius, NumPoints, Color, 0) +#define UE_CVLOG_STAR(Condition, LogOwner, CategoryName, Verbosity, Center, UpAxis, OuterRadius, InnerRadius, NumPoints, Color) if(FVisualLogger::IsRecording() && Condition) {UE_VLOG_STAR(LogOwner, CategoryName, Verbosity, Center, UpAxis, OuterRadius, InnerRadius, NumPoints, Color, 0);} +#define UE_VLOG_STAR_THICK(LogOwner, CategoryName, Verbosity, Center, UpAxis, OuterRadius, InnerRadius, NumPoints, Color, Thickness) if(FVisualLogger::IsRecording()) FStevesVisualLogger::StarLogf(LogOwner, CategoryName, ELogVerbosity::Verbosity, Center, UpAxis, OuterRadius, InnerRadius, NumPoints, Color, Thickness) +#define UE_CVLOG_STAR_THICK(Condition, LogOwner, CategoryName, Verbosity, Center, UpAxis, OuterRadius, InnerRadius, NumPoints, Color, Thickness) if(FVisualLogger::IsRecording() && Condition) {UE_VLOG_STAR_THICK(LogOwner, CategoryName, Verbosity, Center, UpAxis, OuterRadius, InnerRadius, NumPoints, Color, Thickness);} + +#else +#define UE_VLOG_TRIANGLE(LogOwner, CategoryName, Verbosity, Center, UpAxis, Radius, Color) +#define UE_CVLOG_TRIANGLE(Condition, LogOwner, CategoryName, Verbosity, Center, UpAxis, Radius, Color) +#define UE_VLOG_TRIANGLE_THICK(LogOwner, CategoryName, Verbosity, Center, UpAxis, Radius, Color) +#define UE_CVLOG_TRIANGLE_THICK(Condition, LogOwner, CategoryName, Verbosity, Center, UpAxis, Radius, Color) + +// Square shape +#define UE_VLOG_SQUARE(LogOwner, CategoryName, Verbosity, Center, UpAxis, Radius, Color) +#define UE_CVLOG_SQUARE(Condition, LogOwner, CategoryName, Verbosity, Center, UpAxis, Radius, Color) +#define UE_VLOG_SQUARE_THICK(LogOwner, CategoryName, Verbosity, Center, UpAxis, Radius, Color) +#define UE_CVLOG_SQUARE_THICK(Condition, LogOwner, CategoryName, Verbosity, Center, UpAxis, Radius, Color) + +// 2D poly shape +#define UE_VLOG_POLY(LogOwner, CategoryName, Verbosity, Center, UpAxis, Radius, NumPoints, Color) +#define UE_CVLOG_POLY(Condition, LogOwner, CategoryName, Verbosity, Center, UpAxis, Radius, NumPoints, Color) +#define UE_VLOG_POLY_THICK(LogOwner, CategoryName, Verbosity, Center, UpAxis, Radius, NumPoints, Color) +#define UE_CVLOG_POLY_THICK(Condition, LogOwner, CategoryName, Verbosity, Center, UpAxis, Radius, NumPoints, Color) + +// Any 2D star shape +#define UE_VLOG_STAR(LogOwner, CategoryName, Verbosity, Center, UpAxis, OuterRadius, InnerRadius, NumPoints, Color) +#define UE_CVLOG_STAR(Condition, LogOwner, CategoryName, Verbosity, Center, UpAxis, OuterRadius, InnerRadius, NumPoints, Color) +#define UE_VLOG_STAR_THICK(LogOwner, CategoryName, Verbosity, Center, UpAxis, OuterRadius, InnerRadius, NumPoints, Color) +#define UE_CVLOG_STAR_THICK(Condition, LogOwner, CategoryName, Verbosity, Center, UpAxis, OuterRadius, InnerRadius, NumPoints, Color) + +#endif \ No newline at end of file diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpers/StevesUEHelpers.Build.cs b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/StevesUEHelpers.Build.cs new file mode 100644 index 00000000..8c9b4a64 --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpers/StevesUEHelpers.Build.cs @@ -0,0 +1,56 @@ +using UnrealBuildTool; +using System.IO; + +public class StevesUEHelpers : ModuleRules +{ + public StevesUEHelpers(ReadOnlyTargetRules Target) : base(Target) + { + PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs; + + PublicIncludePaths.AddRange( + new string[] { + } + ); + + + PrivateIncludePaths.AddRange( + new string[] { + } + ); + + PublicDependencyModuleNames.AddRange( + new string[] + { + "Core", + "CoreUObject", + "Engine", + "InputCore", + "EnhancedInput", + "Slate", + "SlateCore", + "UMG", + "Paper2D", + "DeveloperSettings" + } + ); + + + PrivateDependencyModuleNames.AddRange( + new string[] + { + "RenderCore", + "PhysicsCore", + "Chaos", + "ChaosCore", + "NavigationSystem" + } + ); + + + DynamicallyLoadedModuleNames.AddRange( + new string[] + { + } + ); + } +} diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpersEd/Private/StevesFixedDataTableCustomisationLayout.cpp b/Plugins/StevesUEHelpers/Source/StevesUEHelpersEd/Private/StevesFixedDataTableCustomisationLayout.cpp new file mode 100644 index 00000000..ba9f7b02 --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpersEd/Private/StevesFixedDataTableCustomisationLayout.cpp @@ -0,0 +1,201 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +#include "StevesFixedDataTableCustomisationLayout.h" + +#include "AssetRegistry/AssetData.h" +#include "Containers/Map.h" +#include "DataTableEditorUtils.h" +#include "Delegates/Delegate.h" +#include "DetailWidgetRow.h" +#include "Editor.h" +#include "Engine/DataTable.h" +#include "Fonts/SlateFontInfo.h" +#include "Framework/Commands/UIAction.h" +#include "HAL/Platform.h" +#include "HAL/PlatformCrt.h" +#include "Internationalization/Internationalization.h" +#include "Internationalization/Text.h" +#include "Misc/Attribute.h" +#include "PropertyCustomizationHelpers.h" +#include "PropertyEditorModule.h" +#include "PropertyHandle.h" +#include "Templates/Casts.h" +#include "UObject/Class.h" +#include "UObject/Object.h" +#include "Widgets/DeclarativeSyntaxSupport.h" +#include "Widgets/Text/STextBlock.h" + +class SToolTip; + +#define LOCTEXT_NAMESPACE "FSsFixedDataTableCustomisationLayout" + +void FStevesFixedDataTableCustomisationLayout::CustomizeHeader(TSharedRef InStructPropertyHandle, class FDetailWidgetRow& HeaderRow, IPropertyTypeCustomizationUtils& StructCustomizationUtils) +{ + DataTablePropertyHandle = InStructPropertyHandle->GetChildHandle("DataTable"); + RowNamePropertyHandle = InStructPropertyHandle->GetChildHandle("RowName"); + + if (InStructPropertyHandle->HasMetaData(TEXT("DataTable"))) + { + // Find data table from asset ref + const FString& DataTablePath = InStructPropertyHandle->GetMetaData(TEXT("DataTable")); + if (UDataTable* DataTable = LoadObject(nullptr, *DataTablePath, nullptr)) + { + UObject* Existing = nullptr; + const bool TablePicked = DataTablePropertyHandle->GetValue(Existing) == FPropertyAccess::Success; + if (!TablePicked || Existing != DataTable) + { + DataTablePropertyHandle->SetValue(DataTable); + } + } + else + { + UE_LOG(LogDataTable, Warning, TEXT("No Datatable found at %s"), *DataTablePath); + } + } + else + { + UE_LOG(LogDataTable, Warning, TEXT("No Datatable meta tag present on property %s"), *InStructPropertyHandle->GetPropertyDisplayName().ToString()); + } + + + + FPropertyComboBoxArgs ComboArgs(RowNamePropertyHandle, + FOnGetPropertyComboBoxStrings::CreateSP(this, &FStevesFixedDataTableCustomisationLayout::OnGetRowStrings), + FOnGetPropertyComboBoxValue::CreateSP(this, &FStevesFixedDataTableCustomisationLayout::OnGetRowValueString)); + ComboArgs.ShowSearchForItemCount = 1; + + + TSharedRef BrowseTableButton = PropertyCustomizationHelpers::MakeBrowseButton( + FSimpleDelegate::CreateSP(this, &FStevesFixedDataTableCustomisationLayout::BrowseTableButtonClicked), + LOCTEXT("SsBrowseToDatatable", "Browse to DataTable in Content Browser")); + HeaderRow + .NameContent() + [ + InStructPropertyHandle->CreatePropertyNameWidget() + ] + .ValueContent() + .MaxDesiredWidth(0.0f) // don't constrain the combo button width + [ + SNew(SHorizontalBox) + +SHorizontalBox::Slot() + .FillWidth(1.0f) + [ + PropertyCustomizationHelpers::MakePropertyComboBox(ComboArgs) + ] + +SHorizontalBox::Slot() + .Padding(2.0f) + .HAlign(HAlign_Center) + .VAlign(VAlign_Center) + .AutoWidth() + [ + BrowseTableButton + ] + ]; ; + + FDataTableEditorUtils::AddSearchForReferencesContextMenu(HeaderRow, FExecuteAction::CreateSP(this, &FStevesFixedDataTableCustomisationLayout::OnSearchForReferences)); +} + +void FStevesFixedDataTableCustomisationLayout::BrowseTableButtonClicked() +{ + if (DataTablePropertyHandle.IsValid()) + { + UObject* SourceDataTable = nullptr; + if (DataTablePropertyHandle->GetValue(SourceDataTable) == FPropertyAccess::Success) + { + TArray Assets; + Assets.Add(SourceDataTable); + GEditor->SyncBrowserToObjects(Assets); + } + } +} + +bool FStevesFixedDataTableCustomisationLayout::GetCurrentValue(UDataTable*& OutDataTable, FName& OutName) const +{ + if (RowNamePropertyHandle.IsValid() && RowNamePropertyHandle->IsValidHandle() && DataTablePropertyHandle.IsValid() && DataTablePropertyHandle->IsValidHandle()) + { + // If either handle is multiple value or failure, fail + UObject* SourceDataTable = nullptr; + if (DataTablePropertyHandle->GetValue(SourceDataTable) == FPropertyAccess::Success) + { + OutDataTable = Cast(SourceDataTable); + + if (RowNamePropertyHandle->GetValue(OutName) == FPropertyAccess::Success) + { + return true; + } + } + } + return false; +} + +void FStevesFixedDataTableCustomisationLayout::OnSearchForReferences() +{ + UDataTable* DataTable; + FName RowName; + + if (GetCurrentValue(DataTable, RowName) && DataTable) + { + TArray AssetIdentifiers; + AssetIdentifiers.Add(FAssetIdentifier(DataTable, RowName)); + + FEditorDelegates::OnOpenReferenceViewer.Broadcast(AssetIdentifiers, FReferenceViewerParams()); + } +} + +FString FStevesFixedDataTableCustomisationLayout::OnGetRowValueString() const +{ + if (!RowNamePropertyHandle.IsValid() || !RowNamePropertyHandle->IsValidHandle()) + { + return FString(); + } + + FName RowNameValue; + const FPropertyAccess::Result RowResult = RowNamePropertyHandle->GetValue(RowNameValue); + if (RowResult == FPropertyAccess::Success) + { + if (RowNameValue.IsNone()) + { + return LOCTEXT("DataTable_None", "None").ToString(); + } + return RowNameValue.ToString(); + } + else if (RowResult == FPropertyAccess::Fail) + { + return LOCTEXT("DataTable_None", "None").ToString(); + } + else + { + return LOCTEXT("MultipleValues", "Multiple Values").ToString(); + } +} + +void FStevesFixedDataTableCustomisationLayout::OnGetRowStrings(TArray< TSharedPtr >& OutStrings, TArray>& OutToolTips, TArray& OutRestrictedItems) const +{ + UDataTable* DataTable = nullptr; + FName IgnoredRowName; + + // Ignore return value as we will show rows if table is the same but row names are multiple values + GetCurrentValue(DataTable, IgnoredRowName); + + TArray AllRowNames; + if (DataTable != nullptr) + { + for (TMap::TConstIterator Iterator(DataTable->GetRowMap()); Iterator; ++Iterator) + { + AllRowNames.Add(Iterator.Key()); + } + + // Sort the names alphabetically. + AllRowNames.Sort(FNameLexicalLess()); + } + + for (const FName& RowName : AllRowNames) + { + OutStrings.Add(MakeShared(RowName.ToString())); + OutRestrictedItems.Add(false); + } +} + + +#undef LOCTEXT_NAMESPACE + diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpersEd/Private/StevesFixedDataTableCustomisationLayout.h b/Plugins/StevesUEHelpers/Source/StevesUEHelpersEd/Private/StevesFixedDataTableCustomisationLayout.h new file mode 100644 index 00000000..8ae17a38 --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpersEd/Private/StevesFixedDataTableCustomisationLayout.h @@ -0,0 +1,40 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +#pragma once + +#include "Containers/Array.h" +#include "Containers/UnrealString.h" +#include "IPropertyTypeCustomization.h" +#include "Templates/SharedPointer.h" +#include "UObject/NameTypes.h" + +class IPropertyHandle; +class SToolTip; +class UDataTable; +class UScriptStruct; +struct FAssetData; + +/// Drop-down for data table row name when the table itself is fixed in meta tags +class FStevesFixedDataTableCustomisationLayout : public IPropertyTypeCustomization +{ +public: + static TSharedRef MakeInstance() + { + return MakeShareable( new FStevesFixedDataTableCustomisationLayout ); + } + + virtual void CustomizeHeader(TSharedRef InStructPropertyHandle, class FDetailWidgetRow& HeaderRow, IPropertyTypeCustomizationUtils& StructCustomizationUtils) override; + // Not needed, but must be implemented + virtual void CustomizeChildren(TSharedRef InStructPropertyHandle, class IDetailChildrenBuilder& StructBuilder, IPropertyTypeCustomizationUtils& StructCustomizationUtils) override {} + +protected: + bool GetCurrentValue(UDataTable*& OutDataTable, FName& OutName) const; + void OnSearchForReferences(); + void OnGetRowStrings(TArray< TSharedPtr >& OutStrings, TArray>& OutToolTips, TArray& OutRestrictedItems) const; + FString OnGetRowValueString() const; + void BrowseTableButtonClicked(); + + TSharedPtr DataTablePropertyHandle; + TSharedPtr RowNamePropertyHandle; + +}; diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpersEd/Private/StevesUEHelpersEd.cpp b/Plugins/StevesUEHelpers/Source/StevesUEHelpersEd/Private/StevesUEHelpersEd.cpp new file mode 100644 index 00000000..b4f55abc --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpersEd/Private/StevesUEHelpersEd.cpp @@ -0,0 +1,22 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +#include "StevesUEHelpersEd.h" +#include "StevesFixedDataTableCustomisationLayout.h" + +#define LOCTEXT_NAMESPACE "FStevesUEHelpersEdModule" + +void FStevesUEHelpersEdModule::StartupModule() +{ + FPropertyEditorModule& PropertyModule = FModuleManager::GetModuleChecked("PropertyEditor"); + PropertyModule.RegisterCustomPropertyTypeLayout("StevesFixedDataTableRowHandle", FOnGetPropertyTypeCustomizationInstance::CreateStatic(&FStevesFixedDataTableCustomisationLayout::MakeInstance)); +} + +void FStevesUEHelpersEdModule::ShutdownModule() +{ + FPropertyEditorModule& PropertyModule = FModuleManager::GetModuleChecked("PropertyEditor"); + PropertyModule.UnregisterCustomPropertyTypeLayout("StevesFixedDataTableRowHandle"); +} + +#undef LOCTEXT_NAMESPACE + +IMPLEMENT_MODULE(FStevesUEHelpersEdModule, StevesUEHelpersEd) \ No newline at end of file diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpersEd/Public/StevesUEHelpersEd.h b/Plugins/StevesUEHelpers/Source/StevesUEHelpersEd/Public/StevesUEHelpersEd.h new file mode 100644 index 00000000..ea2cae1e --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpersEd/Public/StevesUEHelpersEd.h @@ -0,0 +1,13 @@ +// Copyright Steve Streeting 2020 onwards +// Released under the MIT license +#pragma once + +#include "CoreMinimal.h" +#include "Modules/ModuleManager.h" + +class FStevesUEHelpersEdModule : public IModuleInterface +{ +public: + virtual void StartupModule() override; + virtual void ShutdownModule() override; +}; diff --git a/Plugins/StevesUEHelpers/Source/StevesUEHelpersEd/StevesUEHelpersEd.Build.cs b/Plugins/StevesUEHelpers/Source/StevesUEHelpersEd/StevesUEHelpersEd.Build.cs new file mode 100644 index 00000000..570ad758 --- /dev/null +++ b/Plugins/StevesUEHelpers/Source/StevesUEHelpersEd/StevesUEHelpersEd.Build.cs @@ -0,0 +1,30 @@ +using UnrealBuildTool; + +public class StevesUEHelpersEd : ModuleRules +{ + public StevesUEHelpersEd(ReadOnlyTargetRules Target) : base(Target) + { + PCHUsage = ModuleRules.PCHUsageMode.UseExplicitOrSharedPCHs; + + PublicDependencyModuleNames.AddRange( + new string[] + { + "Core", + } + ); + + PrivateDependencyModuleNames.AddRange( + new string[] + { + "CoreUObject", + "Engine", + "Slate", + "SlateCore", + + "UnrealEd", + "PropertyEditor", + "DataTableEditor" + } + ); + } +} \ No newline at end of file diff --git a/Plugins/StevesUEHelpers/StevesUEHelpers.uplugin b/Plugins/StevesUEHelpers/StevesUEHelpers.uplugin new file mode 100644 index 00000000..8bbc7900 --- /dev/null +++ b/Plugins/StevesUEHelpers/StevesUEHelpers.uplugin @@ -0,0 +1,38 @@ +{ + "FileVersion": 3, + "Version": 1, + "VersionName": "1.2.1.0", + "FriendlyName": "Steve's UE Helpers", + "Description": "A set of common helper classes for UE projects by Steve Streeting", + "Category": "Code Utilities", + "CreatedBy": "Steve Streeting", + "CreatedByURL": "https://www.stevestreeting.com", + "DocsURL": "https://github.com/sinbad/StevesUEHelpers/blob/master/ReadMe.md", + "MarketplaceURL": "com.epicgames.launcher://ue/Fab/product/6a48c58c-1771-42f6-b46f-2b09e31b1699", + "SupportURL": "https://github.com/sinbad/StevesUEHelpers", + "EngineVersion": "5.8.0", + "CanContainContent": false, + "Installed": true, + "Modules": [ + { + "Name": "StevesUEHelpers", + "Type": "Runtime", + "LoadingPhase": "PreDefault", + "PlatformAllowList": [ + "Win64", + "Linux", + "Mac" + ] + }, + { + "Name": "StevesUEHelpersEd", + "Type": "Editor", + "LoadingPhase": "PostDefault", + "PlatformAllowList": [ + "Win64", + "Linux", + "Mac" + ] + } + ] +} \ No newline at end of file diff --git a/Source/ProjectEleri/Components/MoveActorsComponent.cpp b/Source/ProjectEleri/Components/MoveActorsComponent.cpp index b36c19bf..8d50f067 100644 --- a/Source/ProjectEleri/Components/MoveActorsComponent.cpp +++ b/Source/ProjectEleri/Components/MoveActorsComponent.cpp @@ -3,7 +3,7 @@ #include "MoveActorsComponent.h" #include "../System/Subsystem/ObjectPersistenceSubsystem.h" -#include "../System/MainlBlueprintFunctionLibrary.h" +#include "../System/MainBlueprintFunctionLibrary.h" #include "Kismet/KismetMathLibrary.h" #include "Kismet/GameplayStatics.h" #include "EnhancedInputSubsystems.h" diff --git a/Source/ProjectEleri/Data/StatDefinitions.cpp b/Source/ProjectEleri/Data/StatDefinitions.cpp new file mode 100644 index 00000000..6e41d0e1 --- /dev/null +++ b/Source/ProjectEleri/Data/StatDefinitions.cpp @@ -0,0 +1,25 @@ + +#pragma once + +#include "StatDefinitions.h" + +STATFORGE_MAKE_STAT(Health) +STATFORGE_MAKE_STAT(MaxHealth) +STATFORGE_MAKE_STAT(MovementSpeedMultiplier) +STATFORGE_MAKE_STAT(Energy) +STATFORGE_MAKE_STAT(MaxEnergy) +STATFORGE_MAKE_STAT(HungerSatiation) +STATFORGE_MAKE_STAT(GatherDoubleDropChance) +STATFORGE_MAKE_STAT(GatherHqDropChance) +STATFORGE_MAKE_STAT(FriendPointsMultiplier) +STATFORGE_MAKE_STAT(AlchemyMultiplier) +STATFORGE_MAKE_STAT(BazarCustomerSpawnRate) +STATFORGE_MAKE_STAT(BazarCustomerWill) + +STATFORGE_MAKE_STAT(AlchemyXp) +STATFORGE_MAKE_STAT(BotanyXp) +STATFORGE_MAKE_STAT(GatheringXp) +STATFORGE_MAKE_STAT(FlyingXp) +STATFORGE_MAKE_STAT(BargainingXp) + +// MAKE_STAT_NS(Combat, AttackSpeed) \ No newline at end of file diff --git a/Source/ProjectEleri/Data/StatDefinitions.h b/Source/ProjectEleri/Data/StatDefinitions.h new file mode 100644 index 00000000..d8e75b69 --- /dev/null +++ b/Source/ProjectEleri/Data/StatDefinitions.h @@ -0,0 +1,29 @@ + +#pragma once + +#include "Data/StatForgeRegistry.h" + +// -Flat Stats- +// MAKE_STAT(Health) +STATFORGE_DECLARE_STAT(Health) +STATFORGE_DECLARE_STAT(MaxHealth) +STATFORGE_DECLARE_STAT(MovementSpeedMultiplier) +STATFORGE_DECLARE_STAT(Energy) +STATFORGE_DECLARE_STAT(MaxEnergy) +STATFORGE_DECLARE_STAT(HungerSatiation) +STATFORGE_DECLARE_STAT(GatherDoubleDropChance) +STATFORGE_DECLARE_STAT(GatherHqDropChance) +STATFORGE_DECLARE_STAT(FriendPointsMultiplier) +STATFORGE_DECLARE_STAT(AlchemyMultiplier) +STATFORGE_DECLARE_STAT(BazarCustomerSpawnRate) +STATFORGE_DECLARE_STAT(BazarCustomerWill) + +STATFORGE_DECLARE_STAT(AlchemyXp) +STATFORGE_DECLARE_STAT(BotanyXp) +STATFORGE_DECLARE_STAT(GatheringXp) +STATFORGE_DECLARE_STAT(FlyingXp) +STATFORGE_DECLARE_STAT(BargainingXp) + +// -Grouped Stats- +// STATFORGE_DECLARE_STAT_NS(Combat, AttackSpeed) +// STATFORGE_DECLARE_STAT_NS(Locomotion, MovementSpeed) \ No newline at end of file diff --git a/Source/ProjectEleri/GameObjects/RemovableStaticMeshActor.cpp b/Source/ProjectEleri/GameObjects/RemovableStaticMeshActor.cpp index b672f505..22f8a982 100644 --- a/Source/ProjectEleri/GameObjects/RemovableStaticMeshActor.cpp +++ b/Source/ProjectEleri/GameObjects/RemovableStaticMeshActor.cpp @@ -7,7 +7,7 @@ #include "../Public/MyCharacter.h" #include "Engine/StaticMeshActor.h" #include "../System/Subsystem/ObjectPersistenceSubsystem.h" -#include "../System/MainlBlueprintFunctionLibrary.h" +#include "../System/MainBlueprintFunctionLibrary.h" #include "../Components/MoveActorsComponent.h" ARemovableStaticMeshActor::ARemovableStaticMeshActor() diff --git a/Source/ProjectEleri/GameplayAbilities/EleriAbilitySystemComponent.cpp b/Source/ProjectEleri/GameplayAbilities/EleriAbilitySystemComponent.cpp index ec215716..f65b2569 100644 --- a/Source/ProjectEleri/GameplayAbilities/EleriAbilitySystemComponent.cpp +++ b/Source/ProjectEleri/GameplayAbilities/EleriAbilitySystemComponent.cpp @@ -4,30 +4,13 @@ #include "EleriAbilitySystemComponent.h" #include "GameplayEffect.h" #include "Attributes/PrimaryAttributeSet.h" -#include "ProjectEleri/System/MainlBlueprintFunctionLibrary.h" +#include "ProjectEleri/System/MainBlueprintFunctionLibrary.h" #include "ProjectEleri/Settings/EleriGameSettings.h" void UEleriAbilitySystemComponent::AddExp(UObject* WorldContextObject, FGameplayAttribute GameplayAttribute, float Amount) { - if (!UPrimaryAttributeSet::IsExpAttribute(GameplayAttribute)) { - return; - } - if (UEleriAbilitySystemComponent* ASC = UMainBlueprintFunctionLibrary::GetAbilitySystemComponentFromPlayer(WorldContextObject)) { - if (const UEleriGameSettings* GameSettings = GetDefault()) { - TSubclassOf ExpEffect = GameSettings->ExpIncreaseEffect; - UGameplayEffect* NewExpEffect = NewObject(WorldContextObject, GameSettings->ExpIncreaseEffect); - if (NewExpEffect->Modifiers.Num() >= 1) { - FGameplayModifierInfo& Mod = NewExpEffect->Modifiers[0]; - Mod.Attribute = GameplayAttribute; - FScalableFloat ScalFloat; - ScalFloat.SetValue(Amount); - Mod.ModifierMagnitude = FGameplayEffectModifierMagnitude(ScalFloat); - ASC->ApplyGameplayEffectToSelf(NewExpEffect, 1.f, ASC->MakeEffectContext()); - } - } - } } void UEleriAbilitySystemComponent::AddProfessionExp(UObject* WorldContextObject, EProfessionType Profession, float Amount) { diff --git a/Source/ProjectEleri/Items/SeedCarousel.cpp b/Source/ProjectEleri/Items/SeedCarousel.cpp index 9f37266c..9f739305 100644 --- a/Source/ProjectEleri/Items/SeedCarousel.cpp +++ b/Source/ProjectEleri/Items/SeedCarousel.cpp @@ -5,7 +5,7 @@ #include "ItemDatabase.h" #include "Kismet/GameplayStatics.h" #include "MyCharacter.h" -#include "ProjectEleri/System/MainlBlueprintFunctionLibrary.h" +#include "ProjectEleri/System/MainBlueprintFunctionLibrary.h" #include "ProjectEleri/System/EleriGameplayTags.h" diff --git a/Source/ProjectEleri/Private/AlchemyManager.cpp b/Source/ProjectEleri/Private/AlchemyManager.cpp index 99672b7d..fceddfd2 100644 --- a/Source/ProjectEleri/Private/AlchemyManager.cpp +++ b/Source/ProjectEleri/Private/AlchemyManager.cpp @@ -8,7 +8,7 @@ #include "EnhancedInputSubsystems.h" #include "ProjectEleri/Public/InventoryComponent.h" #include "ProjectEleri/Items/DataAssets/ItemDataAsset.h" -#include "ProjectEleri/System/MainlBlueprintFunctionLibrary.h" +#include "ProjectEleri/System/MainBlueprintFunctionLibrary.h" AAlchemyManager::AAlchemyManager() { diff --git a/Source/ProjectEleri/Private/EleriGameInstance.cpp b/Source/ProjectEleri/Private/EleriGameInstance.cpp index 03fb167d..d21f55cc 100644 --- a/Source/ProjectEleri/Private/EleriGameInstance.cpp +++ b/Source/ProjectEleri/Private/EleriGameInstance.cpp @@ -6,7 +6,7 @@ #include "MyCharacter.h" #include "../System/Subsystem/ObjectPersistenceSubsystem.h" #include "EngineUtils.h" -#include "ProjectEleri/System/MainlBlueprintFunctionLibrary.h" +#include "ProjectEleri/System/MainBlueprintFunctionLibrary.h" #include "ProjectEleri/Public/EleriGameState.h" DEFINE_LOG_CATEGORY(LogEleriGameInstance); diff --git a/Source/ProjectEleri/Private/EleriGameState.cpp b/Source/ProjectEleri/Private/EleriGameState.cpp index 6c17b6a7..a2626dad 100644 --- a/Source/ProjectEleri/Private/EleriGameState.cpp +++ b/Source/ProjectEleri/Private/EleriGameState.cpp @@ -6,7 +6,7 @@ #include "MyCharacter.h" #include "../System/Subsystem/ObjectPersistenceSubsystem.h" #include "EngineUtils.h" -#include "ProjectEleri/System/MainlBlueprintFunctionLibrary.h" +#include "ProjectEleri/System/MainBlueprintFunctionLibrary.h" #include "ProjectEleri/GameObjects/EleriBaseActor.h" AEleriGameState* AEleriGameState::Get(const UObject* WorldContext) { diff --git a/Source/ProjectEleri/Private/EleriPlayerController.cpp b/Source/ProjectEleri/Private/EleriPlayerController.cpp index 9b5ad743..d811d466 100644 --- a/Source/ProjectEleri/Private/EleriPlayerController.cpp +++ b/Source/ProjectEleri/Private/EleriPlayerController.cpp @@ -7,7 +7,7 @@ #include "EnhancedInputSubsystems.h" #include "GameFramework/CharacterMovementComponent.h" #include "Interface/ZoneInteractableInterface.h" -#include "ProjectEleri/System/MainlBlueprintFunctionLibrary.h" +#include "ProjectEleri/System/MainBlueprintFunctionLibrary.h" #include "ProjectEleri/Components/MoveActorsComponent.h" #include "../GameObjects/RemovableStaticMeshActor.h" #include "Components/WidgetComponent.h" diff --git a/Source/ProjectEleri/Private/EleriSaveGame.cpp b/Source/ProjectEleri/Private/EleriSaveGame.cpp index bce7483b..72e9becc 100644 --- a/Source/ProjectEleri/Private/EleriSaveGame.cpp +++ b/Source/ProjectEleri/Private/EleriSaveGame.cpp @@ -5,7 +5,7 @@ #include "Kismet/GameplayStatics.h" #include "EngineUtils.h" -#include "ProjectEleri/System/MainlBlueprintFunctionLibrary.h" +#include "ProjectEleri/System/MainBlueprintFunctionLibrary.h" #include "ProjectEleri/GameplayAbilities/EleriAbilitySystemComponent.h" #include "ProjectEleri/GameObjects/EleriBaseActor.h" diff --git a/Source/ProjectEleri/Private/MyCharacter.cpp b/Source/ProjectEleri/Private/MyCharacter.cpp index b95639ad..d588a025 100644 --- a/Source/ProjectEleri/Private/MyCharacter.cpp +++ b/Source/ProjectEleri/Private/MyCharacter.cpp @@ -17,12 +17,13 @@ #include "Engine/TextureRenderTarget2D.h" #include "GameFramework/PawnMovementComponent.h" #include "ProjectEleri/GameObjects/BenchmarkActor.h" -#include "ProjectEleri/System/MainlBlueprintFunctionLibrary.h" +#include "ProjectEleri/System/MainBlueprintFunctionLibrary.h" #include "ProjectEleri/UI/InteractionWidget.h" #include "ProjectEleri/GameObjects/BookActor.h" #include "ProjectEleri/GameObjects/BroomActor.h" #include "Components/CapsuleComponent.h" #include "DisplayDebugHelpers.h" +#include "Components/StatForgeComponent.h" #include "Engine/Canvas.h" #include "RTSCameraPlugin/Public/UniversalCamera.h" #include "RTSCameraPlugin/Public/AsyncNode.h" @@ -50,6 +51,7 @@ AMyCharacter::AMyCharacter() EleriAbilitySystemComponent = CreateDefaultSubobject(TEXT("EleriAbilitySystemComponent")); PrimaryAttributeSet = CreateDefaultSubobject(TEXT("PrimaryAttributeSet")); + StatForgeComponent = CreateDefaultSubobject(TEXT("StatForgeComponent")); } bool AMyCharacter::IsEditor() const { diff --git a/Source/ProjectEleri/Private/TimeOfDayManager.cpp b/Source/ProjectEleri/Private/TimeOfDayManager.cpp index d82dc0f5..811cff8f 100644 --- a/Source/ProjectEleri/Private/TimeOfDayManager.cpp +++ b/Source/ProjectEleri/Private/TimeOfDayManager.cpp @@ -18,7 +18,7 @@ #include "Materials/MaterialParameterCollection.h" #include "ProjectEleri/Data/WeatherPresetDataAsset.h" #include "ProjectEleri/GameEventSystem/GameEventSubsystem.h" -#include "ProjectEleri/System/MainlBlueprintFunctionLibrary.h" +#include "ProjectEleri/System/MainBlueprintFunctionLibrary.h" // Sets default values diff --git a/Source/ProjectEleri/ProjectEleri.Build.cs b/Source/ProjectEleri/ProjectEleri.Build.cs index b4a86b95..22cbdfea 100644 --- a/Source/ProjectEleri/ProjectEleri.Build.cs +++ b/Source/ProjectEleri/ProjectEleri.Build.cs @@ -30,7 +30,9 @@ public class ProjectEleri : ModuleRules "UniversalCameraPlugin", "StateTreeModule", "GameplayStateTreeModule", - "DirectiveUtilitiesRuntime" + "DirectiveUtilitiesRuntime", + "StatForge", + "InteractionSystem" }); PrivateDependencyModuleNames.AddRange(new string[] { "OnlineSubsystem", "OnlineSubsystemUtils" }); diff --git a/Source/ProjectEleri/Public/MyCharacter.h b/Source/ProjectEleri/Public/MyCharacter.h index bf7f62ea..8d933147 100644 --- a/Source/ProjectEleri/Public/MyCharacter.h +++ b/Source/ProjectEleri/Public/MyCharacter.h @@ -19,6 +19,7 @@ #include "MyCharacter.generated.h" +class UStatForgeComponent; class AEleriPlayerController; class AWaterBall; class UChildActorComponent; @@ -75,6 +76,9 @@ protected: UPROPERTY(EditDefaultsOnly, BlueprintReadOnly) UEleriAbilitySystemComponent* EleriAbilitySystemComponent; + + UPROPERTY(EditDefaultsOnly, BlueprintReadOnly) + UStatForgeComponent* StatForgeComponent; UPROPERTY() UPrimaryAttributeSet* PrimaryAttributeSet; @@ -108,6 +112,9 @@ public: virtual class UAbilitySystemComponent* GetAbilitySystemComponent() const override { return EleriAbilitySystemComponent; } UFUNCTION(BlueprintCallable, BlueprintPure) UPrimaryAttributeSet* GetPrimaryAttributeSet() { return PrimaryAttributeSet; } + + UFUNCTION(BlueprintCallable, BlueprintPure) + UStatForgeComponent* GetStatForgeComponent() const { return StatForgeComponent; } UPROPERTY(EditDefaultsOnly, BlueprintReadWrite) float CameraPitchMin; diff --git a/Source/ProjectEleri/Settings/EleriGameSettings.h b/Source/ProjectEleri/Settings/EleriGameSettings.h index 495d72c8..a00b836d 100644 --- a/Source/ProjectEleri/Settings/EleriGameSettings.h +++ b/Source/ProjectEleri/Settings/EleriGameSettings.h @@ -5,6 +5,7 @@ #include "CoreMinimal.h" #include "Engine/DeveloperSettings.h" #include "GameplayTagContainer.h" +#include "Data/GameplayStatEffect.h" #include "EleriGameSettings.generated.h" @@ -27,9 +28,9 @@ public: UPROPERTY(Config, EditAnywhere, BlueprintReadOnly, Category = "Camera") TMap CameraZoomLevels; - + UPROPERTY(Config, EditAnywhere, BlueprintReadOnly, Category = "Ability System|Effects") - TSubclassOf ExpIncreaseEffect; + TSubclassOf ExpIncreaseEffectClass; UPROPERTY(Config, EditAnywhere, BlueprintReadOnly, Category = "Ability System|Professions") TArray ProfessionExpArray; @@ -55,5 +56,5 @@ public: UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Ability System|Professions") static int32 GetProfessionLevelForExp(float Exp); - + }; diff --git a/Source/ProjectEleri/System/MainlBlueprintFunctionLibrary.h b/Source/ProjectEleri/System/MainBlueprintFunctionLibrary.h similarity index 52% rename from Source/ProjectEleri/System/MainlBlueprintFunctionLibrary.h rename to Source/ProjectEleri/System/MainBlueprintFunctionLibrary.h index 3dfe2b1a..59dcd156 100644 --- a/Source/ProjectEleri/System/MainlBlueprintFunctionLibrary.h +++ b/Source/ProjectEleri/System/MainBlueprintFunctionLibrary.h @@ -4,14 +4,26 @@ #include "CoreMinimal.h" #include "Kismet/BlueprintFunctionLibrary.h" -#include "MainlBlueprintFunctionLibrary.generated.h" +#include "MainBlueprintFunctionLibrary.generated.h" +class UStatForgeComponent; class URetainerBox; class UMainGameWidget; class UInventoryComponent; class ARemovableStaticMeshActor; class UEleriAbilitySystemComponent; +UENUM(BlueprintType) +enum class EExpType : uint8 +{ + None, + Alchemy, + Botany, + Gathering, + Flying, + Bargaining +}; + /** * */ @@ -23,22 +35,22 @@ class PROJECTELERI_API UMainBlueprintFunctionLibrary : public UBlueprintFunction public: //HTTP REQ - UFUNCTION(BlueprintCallable, Category = "Eleri HTTP") + UFUNCTION(BlueprintCallable, Category = "Eleri|HTTP") static void PostFeedback(const FString& Message); - UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Eleri main", meta = (WorldContext = "WorldContextObject")) + UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Eleri", meta = (WorldContext = "WorldContextObject")) static UInventoryComponent* GetPlayerInventory(const UObject* WorldContextObject); - UFUNCTION(BlueprintCallable, Category = "Eleri main", meta=(WorldContext="WorldContextObject")) + UFUNCTION(BlueprintCallable, Category = "Eleri", meta=(WorldContext="WorldContextObject")) static UMainGameWidget* GetMainGameWidget(UObject* WorldContextObject); - UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Eleri main", meta = (WorldContext = "WorldContextObject")) + UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Eleri", meta = (WorldContext = "WorldContextObject")) static UEleriAbilitySystemComponent* GetAbilitySystemComponentFromActor(const AActor* Actor); - UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Eleri main", meta = (WorldContext = "WorldContextObject")) + UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Eleri", meta = (WorldContext = "WorldContextObject")) static UEleriAbilitySystemComponent* GetAbilitySystemComponentFromPlayer(const UObject* WorldContextObject); - UFUNCTION(BlueprintCallable, Category = "Eleri main") + UFUNCTION(BlueprintCallable, Category = "Eleri") static ARemovableStaticMeshActor* SpawnPlaceable(UWorld* World, UClass* Class, FTransform Transform, FVector ScaleOverride); UFUNCTION(BlueprintCallable, Category = "Dialogue System") @@ -47,21 +59,28 @@ public: UFUNCTION(BlueprintCallable, Category = "Math helpers") static FVector2D GetDirectionalityVector(FVector PawnLoc, FVector PawnForwardVector, FVector CameraLoc); - UFUNCTION(BlueprintCallable, Category = "Eleri Event System", meta = (WorldContext = "WorldContext")) + UFUNCTION(BlueprintCallable, Category = "Eleri|Events", meta = (WorldContext = "WorldContext")) static void StartEvent(const UObject* WorldContext, FSoftObjectPath EventPath); - UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Inventory", meta = (WorldContext = "WorldContext")) + UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Eleri|Inventory", meta = (WorldContext = "WorldContext")) static int32 GetPlayerGold(const UObject* WorldContext); - UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Inventory", meta = (WorldContext = "WorldContext")) + UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Eleri|Inventory", meta = (WorldContext = "WorldContext")) static bool CanSpendGold(const UObject* WorldContext, int32 Amount); - UFUNCTION(BlueprintCallable, Category = "Inventory", meta = (WorldContext = "WorldContext")) + UFUNCTION(BlueprintCallable, Category = "Eleri|Inventory", meta = (WorldContext = "WorldContext")) static void SpendGold(const UObject* WorldContext, int32 Amount); - UFUNCTION(BlueprintCallable, Category = "Inventory", meta = (WorldContext = "WorldContext")) + UFUNCTION(BlueprintCallable, Category = "Eleri|Inventory", meta = (WorldContext = "WorldContext")) static void AddGold(const UObject* WorldContext, int32 Amount); - UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Eleri Helpers", meta = (WorldContext = "WorldContext")) + UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Eleri|Helpers", meta = (WorldContext = "WorldContext")) static float GetCharacterHeight(const UObject* WorldContext, const UClass* Class, bool bScaled = true); + + // STATS + UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Eleri|Stats") + static UStatForgeComponent* GetStatForgeComponentFromActor(AActor* Actor); + + UFUNCTION(BlueprintCallable, Category = "Eleri|Stats", meta = (WorldContext = "WorldContext")) + static void ModifyExp(const UObject* WorldContext, const EExpType ExpType, const float Amount); }; diff --git a/Source/ProjectEleri/System/MainlBlueprintFunctionLibrary.cpp b/Source/ProjectEleri/System/MainlBlueprintFunctionLibrary.cpp index e2f3d551..7d3dd93e 100644 --- a/Source/ProjectEleri/System/MainlBlueprintFunctionLibrary.cpp +++ b/Source/ProjectEleri/System/MainlBlueprintFunctionLibrary.cpp @@ -1,7 +1,7 @@ // Fill out your copyright notice in the Description page of Project Settings. -#include "MainlBlueprintFunctionLibrary.h" +#include "MainBlueprintFunctionLibrary.h" #include "Kismet/GameplayStatics.h" #include "Kismet/KismetStringLibrary.h" #include "../Public/InventoryComponent.h" @@ -10,6 +10,7 @@ #include #include "EleriPlayerController.h" #include "Components/CapsuleComponent.h" +#include "Components/StatForgeComponent.h" #include "ProjectEleri/GameEventSystem/GameEventSubsystem.h" #include "ProjectEleri/GameObjects/BaseCharacter.h" #include "Runtime/Online/HTTP/Public/HttpModule.h" @@ -167,4 +168,54 @@ float UMainBlueprintFunctionLibrary::GetCharacterHeight(const UObject* WorldCont return 0.f; } +UStatForgeComponent* UMainBlueprintFunctionLibrary::GetStatForgeComponentFromActor(AActor* Actor) +{ + if (!Actor) return nullptr; + + if (const AMyCharacter* MyCharacter = Cast(Actor)) + { + return MyCharacter->GetStatForgeComponent(); + } + + return nullptr; +} + +void UMainBlueprintFunctionLibrary::ModifyExp(const UObject* WorldContext, const EExpType ExpType, const float Amount) +{ + if (!WorldContext || !WorldContext->GetWorld()) return; + + UStatForgeComponent* StatForgeComponent = GetStatForgeComponentFromActor(UGameplayStatics::GetPlayerCharacter(WorldContext, 0)); + if (!StatForgeComponent) return; + + const UEleriGameSettings* GameSettings = UEleriGameSettings::GetEleriGameSettings(); + TSubclassOf ExpIncreaseEffect = GameSettings->ExpIncreaseEffectClass; + check(ExpIncreaseEffect); + + TMap DataValueMap; + switch (ExpType) + { + case EExpType::None: + break; + case EExpType::Alchemy: + DataValueMap.Add("Alchemy", Amount); + break; + case EExpType::Botany: + DataValueMap.Add("Botany", Amount); + break; + case EExpType::Gathering: + DataValueMap.Add("Gathering", Amount); + break; + case EExpType::Flying: + DataValueMap.Add("Flying", Amount); + break; + case EExpType::Bargaining: + DataValueMap.Add("Bargaining", Amount); + break; + default: + break; + } + + StatForgeComponent->ApplyGameplayStatEffect(ExpIncreaseEffect, DataValueMap); +} + diff --git a/Source/ProjectEleri/System/Subsystem/ObjectPersistenceSubsystem.cpp b/Source/ProjectEleri/System/Subsystem/ObjectPersistenceSubsystem.cpp index 1b43de27..442239b8 100644 --- a/Source/ProjectEleri/System/Subsystem/ObjectPersistenceSubsystem.cpp +++ b/Source/ProjectEleri/System/Subsystem/ObjectPersistenceSubsystem.cpp @@ -7,7 +7,7 @@ #include "Kismet/GameplayStatics.h" #include "../../Public/EleriSaveGame.h" #include "../GameObjects/RemovableStaticMeshActor.h" -#include "../System/MainlBlueprintFunctionLibrary.h" +#include "../System/MainBlueprintFunctionLibrary.h" int32 UObjectPersistenceSubsystem::ActorExistsInList(AActor* ActorToRemove) {