From 81af1b021438c1073b12a7a5a7c4c30930aa6b0a Mon Sep 17 00:00:00 2001 From: "adnan.mujkic" Date: Sat, 17 Oct 2026 00:42:47 +0200 Subject: [PATCH] Simple cutscene manager and a test cutscene --- .../Trish/S_Trish_MeetDialogue.uasset | 4 +- .../Quests/DA_Quest_Trish_WarmWelcome.uasset | 4 +- .../Quest/LS_Quest_WarmWelcome.uasset | 3 + Content/UI/MainUI.uasset | 4 +- .../DialogueSystem/DialogueComponent.cpp | 150 ++++++ .../DialogueSystem/DialogueComponent.h | 62 ++- .../DialogueSystem/DialogueSceneSubsystem.cpp | 446 ++++++++++++++++++ .../DialogueSystem/DialogueSceneSubsystem.h | 239 ++++++++++ .../DialogueSystem/SpeechDataAsset.h | 64 +++ Source/ProjectEleri/ProjectEleri.Build.cs | 4 +- .../ProjectEleri/Public/UI/MainGameWidget.h | 3 + .../ProjectEleri/QuestSystem/QuestDataAsset.h | 17 + .../QuestSystem/QuestSubsystem.cpp | 28 ++ .../ProjectEleri/QuestSystem/QuestSubsystem.h | 5 + 14 files changed, 1025 insertions(+), 8 deletions(-) create mode 100644 Content/LevelSequence/Quest/LS_Quest_WarmWelcome.uasset create mode 100644 Source/ProjectEleri/DialogueSystem/DialogueSceneSubsystem.cpp create mode 100644 Source/ProjectEleri/DialogueSystem/DialogueSceneSubsystem.h diff --git a/Content/GameData/Dialogue/Characters/Trish/S_Trish_MeetDialogue.uasset b/Content/GameData/Dialogue/Characters/Trish/S_Trish_MeetDialogue.uasset index a7a20845..4d137f62 100644 --- a/Content/GameData/Dialogue/Characters/Trish/S_Trish_MeetDialogue.uasset +++ b/Content/GameData/Dialogue/Characters/Trish/S_Trish_MeetDialogue.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0320dcba9d37c41ab6f218ed6b3560c205e811763a571674a571d3134c72017c -size 4047 +oid sha256:e7a88ea13e20c549243ee22c6fd656f2eba358746ab5f19449b43720ef00ec35 +size 4272 diff --git a/Content/GameData/Quests/DA_Quest_Trish_WarmWelcome.uasset b/Content/GameData/Quests/DA_Quest_Trish_WarmWelcome.uasset index 994ed0ef..0ef23099 100644 --- a/Content/GameData/Quests/DA_Quest_Trish_WarmWelcome.uasset +++ b/Content/GameData/Quests/DA_Quest_Trish_WarmWelcome.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3bee96f2b1bb07c8bb8c1c8e416faff65bd20c26ff39d01178989e353ae6a51b -size 4053 +oid sha256:1fd0097f74fb17149139586083966cf73e7d9cc531d37e666eeebef134fd0fd8 +size 4381 diff --git a/Content/LevelSequence/Quest/LS_Quest_WarmWelcome.uasset b/Content/LevelSequence/Quest/LS_Quest_WarmWelcome.uasset new file mode 100644 index 00000000..33aa7b19 --- /dev/null +++ b/Content/LevelSequence/Quest/LS_Quest_WarmWelcome.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b2124d28818e9f85c4aa61b0cec21cfad1327709f9142601f2d13a390f890deb +size 100287 diff --git a/Content/UI/MainUI.uasset b/Content/UI/MainUI.uasset index 050ae01f..c5959f2b 100644 --- a/Content/UI/MainUI.uasset +++ b/Content/UI/MainUI.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:53579de583914a2b1ceb5e5718d6105c7ca87b3b0cbda15ef7a0b16136cc211a -size 661745 +oid sha256:d7aadec5c63ac88d6b7e0455193931f3d68e77e14f144ba7a1292f6d87fa3ffb +size 671268 diff --git a/Source/ProjectEleri/DialogueSystem/DialogueComponent.cpp b/Source/ProjectEleri/DialogueSystem/DialogueComponent.cpp index 80924c07..e8f2bf1a 100644 --- a/Source/ProjectEleri/DialogueSystem/DialogueComponent.cpp +++ b/Source/ProjectEleri/DialogueSystem/DialogueComponent.cpp @@ -3,6 +3,7 @@ #include "DialogueComponent.h" +#include "DialogueSceneSubsystem.h" #include "DialogueSubsystem.h" #include "DialogueWidget.h" #include "EleriPlayerController.h" @@ -35,6 +36,22 @@ void UDialogueComponent::BeginPlay() EleriPlayerController = Cast(UGameplayStatics::GetPlayerController(this, 0)); } +void UDialogueComponent::EndPlay(const EEndPlayReason::Type EndPlayReason) +{ + // A component taken down mid conversation still has to let go of a cutscene it is holding: the + // sequence would otherwise be left paused on its hold point for good, and the controls it took would + // belong to a scene that nobody is left to finish. + ReleaseHeldScene(); + + if (UDialogueSceneSubsystem* SceneSubsystem = UDialogueSceneSubsystem::Get(this)) + { + SceneSubsystem->OnSceneHeld.RemoveAll(this); + SceneSubsystem->OnSceneFinished.RemoveAll(this); + } + + Super::EndPlay(EndPlayReason); +} + void UDialogueComponent::OnDialogueLoaded() { if(UGameInstance* GameInstance = UGameplayStatics::GetGameInstance(GetWorld())) @@ -77,6 +94,35 @@ void UDialogueComponent::TriggerDialogue_Async(TSoftObjectPtr } void UDialogueComponent::TriggerDialogue(USpeechDataAsset* DialogueAssetPath, const TMap& ReplacementMap) +{ + if (DialogueAssetPath == nullptr) return; + + // A dialogue can be a cutscene. Two shapes of that are supported, and the asset picks between them + // with its hold marker: a scene that plays through and hands over at its end, and a scene that plays + // as far as its hold point and stays there while the conversation happens inside it. + const EDialogueSceneStart SceneStart = StartDialogueScene(DialogueAssetPath, EDialogueSceneTiming::OnDialogueStart); + + if (SceneStart != EDialogueSceneStart::None && DialogueAssetPath->bHoldDialogueForScene) + { + PendingDialogue = DialogueAssetPath; + PendingDialogueReplacementMap = ReplacementMap; + + // Both beats are listened for on the off chance the other one arrives: a held scene reports its + // hold and later its end, and a scene that plays through only ever reports its end. Whichever + // comes first starts the dialogue, and StartPendingDialogue lets go of both. + if (UDialogueSceneSubsystem* SceneSubsystem = UDialogueSceneSubsystem::Get(this)) + { + SceneSubsystem->OnSceneHeld.AddUniqueDynamic(this, &UDialogueComponent::OnDialogueSceneHeld); + SceneSubsystem->OnSceneFinished.AddUniqueDynamic(this, &UDialogueComponent::OnDialogueSceneFinished); + } + + return; + } + + StartDialogueInternal(DialogueAssetPath, ReplacementMap); +} + +void UDialogueComponent::StartDialogueInternal(USpeechDataAsset* DialogueAssetPath, const TMap& ReplacementMap) { UDialogueSubsystem* DialogueSubsystem = UDialogueSubsystem::Get(this); if (!DialogueSubsystem) return; @@ -139,12 +185,28 @@ void UDialogueComponent::CloseDialogue() UDialogueSubsystem* DialogueSubsystem = UDialogueSubsystem::Get(this); if (!DialogueSubsystem) return; DialogueSubsystem->EndDialogue(); + + // Held before the state below is cleared, because a scene that closes a conversation is played from + // the asset that is about to be forgotten. + const USpeechDataAsset* ClosedDialogue = CurrentActiveDialogue; + + // A cutscene this conversation was happening inside of is let go of before anything else is tried: + // the sequence carries on from its hold point and plays its own tail, and while it is doing that no + // other scene can start anyway, which is why an end scene below is allowed to be refused. + ReleaseHeldScene(); SelectedDialogueOptionIndex = 0; CurrentLineIndex = 0; CurrentActiveDialogue = nullptr; UnbindControllerInput(); EleriPlayerController->ToggleDialogueWindow(false); + + // Played last, once the window is down and the conversation is over, so the cutscene is what the + // player is left watching rather than something the dialogue box sits on top of. + if (ClosedDialogue != nullptr) + { + StartDialogueScene(ClosedDialogue, EDialogueSceneTiming::OnDialogueEnd); + } } void UDialogueComponent::AdvanceDialogue() @@ -276,5 +338,93 @@ TArray UDialogueComponent::GetDialogueOptionsFromPlayerSpeakList(const TA return Options; } +EDialogueSceneStart UDialogueComponent::StartDialogueScene(const USpeechDataAsset* DialogueAsset, EDialogueSceneTiming Timing) +{ + if (DialogueAsset == nullptr || DialogueAsset->Scene.IsNull() || DialogueAsset->SceneTiming != Timing) + { + return EDialogueSceneStart::None; + } + + UDialogueSceneSubsystem* SceneSubsystem = UDialogueSceneSubsystem::Get(this); + if (SceneSubsystem == nullptr) + { + return EDialogueSceneStart::None; + } + + // The actor the component sits on goes in as the instigator, so a listener can tell which NPC a + // cutscene belongs to without having to remember what it asked for. + // + // A scene with a hold marker is asked to hold, and one without is asked to play through: that choice + // on the asset is the whole difference between a conversation that happens inside a cutscene and one + // that happens after it. + if (!DialogueAsset->SceneHoldMarker.IsNone()) + { + return SceneSubsystem->PlaySceneHeld(DialogueAsset->Scene, DialogueAsset->SceneHoldMarker, GetOwner()) + ? EDialogueSceneStart::StartedHeld + : EDialogueSceneStart::None; + } + + return SceneSubsystem->PlayScene(DialogueAsset->Scene, GetOwner()) + ? EDialogueSceneStart::Started + : EDialogueSceneStart::None; +} + +void UDialogueComponent::OnDialogueSceneFinished(ULevelSequence* Scene, AActor* Instigator) +{ + bHoldingSceneForDialogue = false; + StartPendingDialogue(); +} + +void UDialogueComponent::OnDialogueSceneHeld(ULevelSequence* Scene, AActor* Instigator) +{ + // The scene is paused on its hold point and is staying there, so the conversation runs inside the + // cutscene and CloseDialogue is what lets it go again. + bHoldingSceneForDialogue = true; + StartPendingDialogue(); +} + +void UDialogueComponent::StartPendingDialogue() +{ + // Both scene events are let go of first: waiting behind a scene is a one-off, and a dialogue started + // from here may well have a scene of its own that must not be swallowed by a stale binding. + if (UDialogueSceneSubsystem* SceneSubsystem = UDialogueSceneSubsystem::Get(this)) + { + SceneSubsystem->OnSceneHeld.RemoveDynamic(this, &UDialogueComponent::OnDialogueSceneHeld); + SceneSubsystem->OnSceneFinished.RemoveDynamic(this, &UDialogueComponent::OnDialogueSceneFinished); + } + + USpeechDataAsset* WaitingDialogue = PendingDialogue; + PendingDialogue = nullptr; + + // A scene that was not holding a dialogue still comes through here, because the bindings are made + // before the manager decides whether it can play anything. Waiting on nothing is the ordinary case. + if (WaitingDialogue == nullptr) + { + return; + } + + const TMap ReplacementMap = PendingDialogueReplacementMap; + PendingDialogueReplacementMap.Reset(); + + StartDialogueInternal(WaitingDialogue, ReplacementMap); +} + +void UDialogueComponent::ReleaseHeldScene() +{ + if (!bHoldingSceneForDialogue) + { + return; + } + + bHoldingSceneForDialogue = false; + + // Letting the sequence go is all that is needed: it resumes from its hold point, plays its tail, and + // stops at the end of it, which is what returns the player's camera and their controls. + if (UDialogueSceneSubsystem* SceneSubsystem = UDialogueSceneSubsystem::Get(this)) + { + SceneSubsystem->ResumeScene(); + } +} + #undef LOCTEXT_NAMESPACE diff --git a/Source/ProjectEleri/DialogueSystem/DialogueComponent.h b/Source/ProjectEleri/DialogueSystem/DialogueComponent.h index 4cf6a9ac..e77f927d 100644 --- a/Source/ProjectEleri/DialogueSystem/DialogueComponent.h +++ b/Source/ProjectEleri/DialogueSystem/DialogueComponent.h @@ -11,8 +11,26 @@ struct FPlayerSpeakToDialogue; class AEleriPlayerController; class UDialogueWidget; +class ULevelSequence; class UWidgetComponent; +/** + * What happened when a dialogue asked for its scene, which is also the question "what is the dialogue + * waiting behind, if anything?". + */ +enum class EDialogueSceneStart : uint8 +{ + /** No scene was due, or the one that was due could not start: the dialogue runs on its own */ + None, + + /** The scene is playing through, so the dialogue waits for its end */ + Started, + + /** The scene has been asked to hold, so the dialogue waits for the hold point and then runs inside + * the cutscene. The scene is let go again when the dialogue ends. */ + StartedHeld +}; + UCLASS(BlueprintType, Blueprintable) class PROJECTELERI_API UDialogueComponent : public UActorComponent { @@ -49,6 +67,32 @@ protected: void TriggerDialogueSelect(TArray& DialogueOptions, bool bIsDialogueStart); UFUNCTION() void OnDialogueOptionSelected(int32 Index); + /** Runs a dialogue. TriggerDialogue hands over to this once any scene in front of the dialogue has + * had its turn, so the conversation is set up in one place whichever way it was reached. */ + void StartDialogueInternal(USpeechDataAsset* DialogueAsset, const TMap& ReplacementMap); + + /** Plays the scene the asset names, when it names one and it is due at Timing, and says what the + * dialogue is now waiting behind. None means nothing started and the dialogue carries on. */ + EDialogueSceneStart StartDialogueScene(const USpeechDataAsset* DialogueAsset, EDialogueSceneTiming Timing); + + /** A scene the dialogue was waiting behind has finished, so the dialogue starts. Bound on + * TriggerDialogue and let go of here, which is what keeps the wait to one conversation. */ + UFUNCTION() + void OnDialogueSceneFinished(ULevelSequence* Scene, AActor* Instigator); + + /** A scene the dialogue was waiting behind has reached its hold point, so the dialogue starts inside + * the cutscene. The scene stays where it is until the dialogue ends and lets it go. */ + UFUNCTION() + void OnDialogueSceneHeld(ULevelSequence* Scene, AActor* Instigator); + + /** Starts whatever dialogue was left waiting behind a scene, whichever beat it was waiting for, and + * lets go of both scene events so only one conversation can ever be waiting at a time. */ + void StartPendingDialogue(); + + /** Lets go of a scene this dialogue put on hold, so the cutscene finishes and the player gets their + * controls back. Does nothing when this dialogue is not holding anything. */ + void ReleaseHeldScene(); + static TArray GetPlayerAsksFromDialogueList(const TArray& DialogueList); static TArray GetDialogueOptionsFromPlayerSpeakList(const TArray& SpeakList); @@ -78,11 +122,27 @@ protected: TMap MappedDialogueOptions; UPROPERTY(EditAnywhere, BlueprintReadWrite) TMap CurrentDialogueReplacementMap; - + /** The dialogue held behind a scene that is still playing, and the replacement map it was asked + * for with. Kept rather than re-derived, because the caller that asked for the dialogue is not + * around by the time the cutscene ends. */ + UPROPERTY() + TObjectPtr PendingDialogue = nullptr; + + UPROPERTY() + TMap PendingDialogueReplacementMap; + + /** True while this component is what is holding a cutscene open. It is what tells CloseDialogue + * that the scene has a tail to play before the conversation is really over. */ + bool bHoldingSceneForDialogue = false; + protected: // Called when the game starts virtual void BeginPlay() override; + /** A component that is taken down mid conversation still has to let go of a cutscene it is holding, + * or the player would be left standing in a paused shot with no controls and no way back. */ + virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override; + UFUNCTION() void OnDialogueLoaded(); diff --git a/Source/ProjectEleri/DialogueSystem/DialogueSceneSubsystem.cpp b/Source/ProjectEleri/DialogueSystem/DialogueSceneSubsystem.cpp new file mode 100644 index 00000000..1c4dde5a --- /dev/null +++ b/Source/ProjectEleri/DialogueSystem/DialogueSceneSubsystem.cpp @@ -0,0 +1,446 @@ +// Fill out your copyright notice in the Description page of Project Settings. + + +#include "DialogueSceneSubsystem.h" + +#include "EleriPlayerController.h" +#include "Engine/World.h" +#include "GameFramework/PlayerController.h" +#include "Kismet/GameplayStatics.h" +#include "LevelSequence.h" +#include "LevelSequenceActor.h" +#include "LevelSequencePlayer.h" +#include "MovieScene.h" +#include "MovieSceneSequencePlaybackSettings.h" + +UDialogueSceneSubsystem* UDialogueSceneSubsystem::Get(const UObject* WorldContext) +{ + const UGameInstance* GameInstance = UGameplayStatics::GetGameInstance(WorldContext); + return GameInstance != nullptr ? GameInstance->GetSubsystem() : nullptr; +} + +void UDialogueSceneSubsystem::Initialize(FSubsystemCollectionBase& Collection) +{ + Super::Initialize(Collection); + + WorldCleanupHandle = FWorldDelegates::OnWorldCleanup.AddUObject( + this, &UDialogueSceneSubsystem::HandleWorldCleanup); +} + +void UDialogueSceneSubsystem::Deinitialize() +{ + if (WorldCleanupHandle.IsValid()) + { + FWorldDelegates::OnWorldCleanup.Remove(WorldCleanupHandle); + WorldCleanupHandle.Reset(); + } + + // Nothing here is stopped on the way out: the actor and the player live in a world that is being + // destroyed, and the controller this would hand controls back to is going with it. What is left to + // do is forget them, which is what keeps a scene that was running from being reported as still + // running in the next world. + ResetSceneTracking(); + ActiveSequenceActor = nullptr; + + Super::Deinitialize(); +} + +UWorld* UDialogueSceneSubsystem::GetSceneWorld() const +{ + const UGameInstance* GameInstance = GetGameInstance(); + return GameInstance != nullptr ? GameInstance->GetWorld() : nullptr; +} + +APlayerController* UDialogueSceneSubsystem::GetScenePlayerController() const +{ + UWorld* World = GetSceneWorld(); + return World != nullptr ? World->GetFirstPlayerController() : nullptr; +} + +bool UDialogueSceneSubsystem::PlayScene(TSoftObjectPtr Scene, AActor* Instigator, bool bDisablePlayerControl) +{ + // One scene at a time. Two sequences at once would both be driving the camera and the player's + // actor, and whichever ended first would hand the controls back in the middle of the other one. So + // a request that arrives while a scene is running is refused rather than layered on top of it, and + // the caller carries on as though it had never asked. This is also what keeps a quest that changes + // status twice in one frame from cutting its own cutscene short. + if (bScenePlaying) + { + UE_LOG(LogTemp, Warning, + TEXT("[DialogueScene] '%s' was asked for while '%s' is still playing, so it was not started."), + *Scene.ToString(), + ActiveScene != nullptr ? *ActiveScene->GetName() : TEXT("")); + + return false; + } + + if (Scene.IsNull()) + { + return false; + } + + // Blocking on the load is deliberate. The caller is asking for a cutscene it is about to hold + // everything up for anyway, and returning false here would leave a dialogue waiting on a scene + // that is never going to start. The object is already in memory whenever the caller got it from an + // asset it has loaded, which is the ordinary case. + ULevelSequence* LoadedScene = Scene.LoadSynchronous(); + if (LoadedScene == nullptr) + { + UE_LOG(LogTemp, Warning, TEXT("[DialogueScene] '%s' could not be loaded, so no scene was played."), + *Scene.ToString()); + return false; + } + + return StartScene(LoadedScene, NAME_None, Instigator, bDisablePlayerControl); +} + +bool UDialogueSceneSubsystem::PlaySceneHeld(TSoftObjectPtr Scene, FName HoldMarker, AActor* Instigator, bool bDisablePlayerControl) +{ + if (bScenePlaying) + { + UE_LOG(LogTemp, Warning, + TEXT("[DialogueScene] '%s' was asked for, held, while '%s' is still playing, so it was not started."), + *Scene.ToString(), + ActiveScene != nullptr ? *ActiveScene->GetName() : TEXT("")); + + return false; + } + + if (Scene.IsNull()) + { + return false; + } + + ULevelSequence* LoadedScene = Scene.LoadSynchronous(); + if (LoadedScene == nullptr) + { + UE_LOG(LogTemp, Warning, TEXT("[DialogueScene] '%s' could not be loaded, so no scene was played."), + *Scene.ToString()); + return false; + } + + // A hold that cannot be resolved is dropped rather than passed on: the scene still plays, it just + // plays through, which is a cutscene with a missing beat instead of a cutscene that rewinds. + FName ResolvedHold = HoldMarker; + if (!HoldMarker.IsNone() && !TryResolveHoldMarker(LoadedScene, HoldMarker)) + { + ResolvedHold = NAME_None; + } + + return StartScene(LoadedScene, ResolvedHold, Instigator, bDisablePlayerControl); +} + +bool UDialogueSceneSubsystem::TryResolveHoldMarker(const ULevelSequence* Scene, FName HoldMarker) +{ + const UMovieScene* MovieScene = Scene != nullptr ? Scene->GetMovieScene() : nullptr; + if (MovieScene == nullptr) + { + UE_LOG(LogTemp, Warning, + TEXT("[DialogueScene] '%s' has no movie scene, so it cannot be held."), + Scene != nullptr ? *Scene->GetName() : TEXT("")); + return false; + } + + const FString Label = HoldMarker.ToString(); + const int32 MarkerIndex = MovieScene->FindMarkedFrameByLabel(Label); + if (MarkerIndex == INDEX_NONE) + { + UE_LOG(LogTemp, Warning, + TEXT("[DialogueScene] '%s' has no marker called '%s', so it will play through instead of holding. " + "Add the marker in Sequencer, or clear the hold marker on the dialogue."), + *Scene->GetName(), *Label); + return false; + } + + // The player resolves a marker it cannot find to its current time, and PlayTo reads a target that + // is not ahead of the cursor as a request to play backwards. A marker sitting at or before the + // start would therefore rewind the whole cutscene, so it is refused here where it can be reported. + const FFrameNumber MarkerFrame = MovieScene->GetMarkedFrames()[MarkerIndex].FrameNumber; + const TRangeBound LowerBound = MovieScene->GetPlaybackRange().GetLowerBound(); + const FFrameNumber StartFrame = LowerBound.IsOpen() ? FFrameNumber(0) : LowerBound.GetValue(); + if (MarkerFrame <= StartFrame) + { + UE_LOG(LogTemp, Warning, + TEXT("[DialogueScene] marker '%s' on '%s' sits at or before the start of the sequence, so it cannot be " + "held at. Move it after the first frame."), + *Label, *Scene->GetName()); + return false; + } + + return true; +} + +bool UDialogueSceneSubsystem::StartScene(ULevelSequence* Scene, FName HoldMarker, AActor* Instigator, bool bDisablePlayerControl) +{ + UWorld* World = GetSceneWorld(); + if (World == nullptr) + { + return false; + } + + // One actor for the life of the subsystem, pointed at each new sequence, rather than one actor per + // scene. A level would otherwise collect an actor for every cutscene the player watched, and taking + // such an actor down would have to happen from inside the sequence player's very own finished + // callback, which is the one moment the player is still in the middle of broadcasting to it. + ULevelSequencePlayer* Player = nullptr; + + if (!IsValid(ActiveSequenceActor.Get())) + { + FMovieSceneSequencePlaybackSettings Settings; + // Not auto-played by the factory: the delegates below are bound and the control is taken before + // the sequence is allowed to run, so a scene that ends on its first frame cannot outrun its own + // setup. + Settings.bAutoPlay = false; + + ALevelSequenceActor* SequenceActor = nullptr; + Player = ULevelSequencePlayer::CreateLevelSequencePlayer(World, Scene, Settings, SequenceActor); + if (Player == nullptr || SequenceActor == nullptr) + { + return false; + } + + ActiveSequenceActor = SequenceActor; + } + else + { + ActiveSequenceActor->SetSequence(Scene); + + // SetSequence only takes while the sequence player is not running, which is the case here: the + // scene that used it has stopped. A refusal is reported as a scene that never started. + if (ActiveSequenceActor->GetSequence() != Scene) + { + return false; + } + + Player = ActiveSequenceActor->GetSequencePlayer(); + if (Player == nullptr) + { + return false; + } + } + + ActiveScene = Scene; + ActivePlayer = Player; + SceneInstigator = Instigator; + bScenePlaying = true; + bControlTakenByScene = bDisablePlayerControl; + bHoldRequested = !HoldMarker.IsNone(); + bSceneHeld = false; + + // Both ends are listened for rather than only OnFinished, because Stop() takes the other route out + // and a scene cut short still has to give the player their controls back. + Player->OnFinished.AddDynamic(this, &UDialogueSceneSubsystem::HandleSceneFinished); + Player->OnStop.AddDynamic(this, &UDialogueSceneSubsystem::HandleSceneStopped); + + // A hold is noticed through the player's own pause. PlayTo asks the player to pause itself on the + // marker frame, so the pause arriving *is* the hold being reached. It is only bound for a scene + // that asked to hold, so a pause from anywhere else cannot be read as one. + if (bHoldRequested) + { + Player->OnPause.AddDynamic(this, &UDialogueSceneSubsystem::HandleScenePaused); + } + + if (bDisablePlayerControl) + { + TakePlayerControl(); + } + + // Announced before the sequence runs, so a listener is already listening when a scene that ends on + // its first frame reports itself finished from inside this call. + OnSceneStarted.Broadcast(Scene, Instigator); + + if (bHoldRequested) + { + // Plays forward and stops itself on the marker. The label has already been checked against the + // sequence, so the player cannot resolve it to the current time and run the scene backwards. + FMovieSceneSequencePlaybackParams PlaybackParams(HoldMarker.ToString(), EUpdatePositionMethod::Play); + FMovieSceneSequencePlayToParams PlayToParams; + // Inclusive, so the marked frame itself is evaluated and the hold does not land a frame early. + PlayToParams.bExclusive = false; + + Player->PlayTo(PlaybackParams, PlayToParams); + } + else + { + Player->Play(); + } + + if (AEleriPlayerController* PlayerController = Cast(GetScenePlayerController())) + { + PlayerController->GetMainGameWidget()->ToggleUiForCutscene(true); + } + + return true; +} + +void UDialogueSceneSubsystem::HandleScenePaused() +{ + // OnPause fires for any pause the player takes, so the hold is only announced for the one that was + // asked for, and only the first time: a listener that opens a dialogue here is not opening two. + if (!bHoldRequested || bSceneHeld || !bScenePlaying) + { + return; + } + + bSceneHeld = true; + + // The camera and everything else the scene was driving are still on screen and still frozen here, + // which is the whole point of the beat: a listener can put something of its own in front of it. + OnSceneHeld.Broadcast(ActiveScene, SceneInstigator.Get()); +} + +bool UDialogueSceneSubsystem::HoldScene() +{ + if (!bScenePlaying || !IsValid(ActivePlayer.Get()) || ActivePlayer->IsPaused()) + { + return false; + } + + // Marked as a hold before the pause is asked for, so the OnPause it causes is read as a hold point + // rather than as a pause from somewhere else. + bHoldRequested = true; + ActivePlayer->Pause(); + + return true; +} + +bool UDialogueSceneSubsystem::ResumeScene() +{ + if (!bScenePlaying || !IsValid(ActivePlayer.Get()) || !ActivePlayer->IsPaused()) + { + return false; + } + + bSceneHeld = false; + bHoldRequested = false; + + // Resumes from where it was held and runs the tail to its end, which is where the stop, the return + // of the player's camera and the hand-back of the controls all happen by themselves. + ActivePlayer->Play(); + + return true; +} + +void UDialogueSceneSubsystem::StopScene() +{ + if (!bScenePlaying || ActivePlayer == nullptr) + { + return; + } + + // Stop() reports through OnStop, which is the same path a scene that ran to its end takes, so the + // hand-back of the controls and the finished broadcast stay in the one place they are written. + ActivePlayer->Stop(); +} + +void UDialogueSceneSubsystem::HandleSceneFinished() +{ + FinishScene(); +} + +void UDialogueSceneSubsystem::HandleSceneStopped() +{ + FinishScene(); +} + +void UDialogueSceneSubsystem::FinishScene() +{ + if (!bScenePlaying) + { + // OnStop and OnFinished can both arrive for one scene, since a sequence that is stopped part way + // is stopped and one that reaches its end reports both. The flag is what makes the second + // arrival a no-op instead of a second hand-back of the controls. + return; + } + + ULevelSequence* FinishedScene = ActiveScene; + AActor* Instigator = SceneInstigator.Get(); + + // The controls go back before anyone is told the scene is over, because a listener that starts a + // dialogue or another scene from here has to do it in a world the player already has back. + RestorePlayerControl(); + ResetSceneTracking(); + + if (AEleriPlayerController* PlayerController = Cast(GetScenePlayerController())) + { + PlayerController->GetMainGameWidget()->ToggleUiForCutscene(false); + } + + OnSceneFinished.Broadcast(FinishedScene, Instigator); +} + +void UDialogueSceneSubsystem::ResetSceneTracking() +{ + if (ActivePlayer != nullptr) + { + // The player outlives this call, so its events have to be let go of explicitly: a scene that + // was stopped would otherwise report itself finished a second time. + ActivePlayer->OnFinished.RemoveAll(this); + ActivePlayer->OnStop.RemoveAll(this); + ActivePlayer->OnPause.RemoveAll(this); + } + + ActivePlayer = nullptr; + ActiveScene = nullptr; + SceneInstigator = nullptr; + bScenePlaying = false; + bControlTakenByScene = false; + + // A scene that is over cannot still be held, whichever way it ended: a stopped scene leaves the + // pause behind with it, and the next scene must not inherit the hold of the one before. + bHoldRequested = false; + bSceneHeld = false; + + // ActiveSequenceActor is deliberately left alone: the actor it points at is reused by the next + // scene and belongs to the world, so it is taken down by the world going away and not by a scene + // ending. +} + +void UDialogueSceneSubsystem::TakePlayerControl() +{ + APlayerController* PlayerController = GetScenePlayerController(); + if (PlayerController == nullptr) + { + // No controller means no player to hold back, so the scene is recorded as one that never took + // the controls: handing them back later would be acting on a controller that did not exist + // when the scene started. + bControlTakenByScene = false; + return; + } + + // Movement and looking only. The player stays visible and the HUD keeps drawing, because a cutscene + // that also hides both is a different thing from one that only wants the controls, and the two are + // a flag apart for a caller that wants the other. + PlayerController->SetCinematicMode(true, /*bHidePlayer=*/false, /*bAffectsHUD=*/false, + /*bAffectsMovement=*/true, /*bAffectsTurning=*/true); +} + +void UDialogueSceneSubsystem::RestorePlayerControl() +{ + if (!bControlTakenByScene) + { + return; + } + + if (APlayerController* PlayerController = GetScenePlayerController()) + { + PlayerController->SetCinematicMode(false, /*bHidePlayer=*/false, /*bAffectsHUD=*/false, + /*bAffectsMovement=*/true, /*bAffectsTurning=*/true); + } +} + +void UDialogueSceneSubsystem::HandleWorldCleanup(UWorld* World, bool bSessionEnded, bool bCleanupResources) +{ + (void)bSessionEnded; + (void)bCleanupResources; + + // Only the world the scene is playing in matters. Another world coming down, an editor preview or a + // streaming world, must not be read as the scene being over, because the scene is still on screen + // in the first one. + if (World == nullptr || World != GetSceneWorld()) + { + return; + } + + ResetSceneTracking(); +} diff --git a/Source/ProjectEleri/DialogueSystem/DialogueSceneSubsystem.h b/Source/ProjectEleri/DialogueSystem/DialogueSceneSubsystem.h new file mode 100644 index 00000000..479cafb1 --- /dev/null +++ b/Source/ProjectEleri/DialogueSystem/DialogueSceneSubsystem.h @@ -0,0 +1,239 @@ +// Fill out your copyright notice in the Description page of Project Settings. + +#pragma once + +#include "CoreMinimal.h" +#include "Subsystems/GameInstanceSubsystem.h" +#include "UObject/SoftObjectPtr.h" + +#include "DialogueSceneSubsystem.generated.h" + +class AActor; +class ALevelSequenceActor; +class APlayerController; +class ULevelSequence; +class ULevelSequencePlayer; +class UWorld; + +DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnDialogueSceneStarted, ULevelSequence*, Scene, AActor*, Instigator); +DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnDialogueSceneHeld, ULevelSequence*, Scene, AActor*, Instigator); +DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnDialogueSceneFinished, ULevelSequence*, Scene, AActor*, Instigator); + +/** + * The one thing in the project that plays a cutscene. + * + * A dialogue scene is a level sequence and nothing more, which is what keeps writing one in Sequencer + * rather than in C++. What this subsystem adds on top of the sequence player is the part the engine + * does not do for a game: whoever asked for the scene owns the player while it runs, and the player is + * handed their controls back the moment it stops, however it stopped. + * + * That is why every caller goes through here instead of spawning an ALevelSequenceActor itself: + * + * - The scene on screen is remembered, so a second request while one is running is refused rather + * than two sequences fighting over the camera and over the player's actor. + * - The controls are taken with APlayerController::SetCinematicMode, which is a flag on the controller + * and therefore is not undone by the widget code that swaps input mapping contexts around a menu. + * - OnFinished and OnStop are treated as one event, because a sequence can end either way and the + * controls have to come back for both. + * + * A scene can also be held, which is what turns one sequence into a conversation rather than a film: + * the sequence plays as far as a marker and stays there, the camera cut and everything else it drove + * frozen on screen, while the world carries on and a dialogue plays over the top. When the dialogue is + * done the scene is let go and runs its tail, which is where the fade out and the release live. See + * PlaySceneHeld for the mechanics. + * + * A game instance subsystem, like the dialogue and quest subsystems and for the same reason: a scene + * started in one level must not still read as playing in the next, so the tracking is dropped when the + * world it was playing in is torn down. + */ +UCLASS() +class PROJECTELERI_API UDialogueSceneSubsystem : public UGameInstanceSubsystem +{ + GENERATED_BODY() + +public: + + // ----- Lifetime --------------------------------------------------------------- + + virtual void Initialize(FSubsystemCollectionBase& Collection) override; + virtual void Deinitialize() override; + + /** The subsystem of the game instance the context belongs to, or null when there is none. */ + static UDialogueSceneSubsystem* Get(const UObject* WorldContext); + + // ----- Delegates -------------------------------------------------------------- + + /** Fired as a scene starts, before it is played, so a listener can set up for a scene that may + * finish inside the very call that started it. */ + UPROPERTY(BlueprintAssignable, Category = "Eleri|Dialogue") + FOnDialogueSceneStarted OnSceneStarted; + + /** Fired once a scene is over and the player has their controls back. This is the event a dialogue + * waits on when a cutscene has to be seen before its first line. */ + UPROPERTY(BlueprintAssignable, Category = "Eleri|Dialogue") + FOnDialogueSceneFinished OnSceneFinished; + + /** + * Fired when a scene has reached its hold point and is paused there. + * + * This is a different beat from OnSceneFinished and not a step on the way to it: the scene is still + * on screen, still owns the camera, and is going to carry on later. A listener that starts the + * dialogue from here is what puts a conversation inside a cutscene instead of after one. + */ + UPROPERTY(BlueprintAssignable, Category = "Eleri|Dialogue") + FOnDialogueSceneHeld OnSceneHeld; + + // ----- Playing ----------------------------------------------------------------- + + /** + * Plays Scene as a cutscene and keeps track of it until it stops. + * + * Instigator is whoever asked for the scene, reported back on both delegates so a listener can tell + * the scene a quest started from the scene a conversation started. It is optional, because a scene + * that belongs to no actor at all is a perfectly ordinary scene. + * + * bDisablePlayerControl takes the player's movement and looking away for the length of the scene + * and is the default, since that is what makes a cutscene a cutscene. Pass false for a scene that + * is only meant to drive the camera while the player keeps playing. + * + * Returns false when there is nothing to play, when the sequence could not be loaded, or when a + * scene is already on screen. A caller that gets false is free to carry on as if it had never asked, + * which is what lets a dialogue run instead of waiting for a cutscene that never started. + */ + UFUNCTION(BlueprintCallable, Category = "Eleri|Dialogue") + bool PlayScene(TSoftObjectPtr Scene, AActor* Instigator = nullptr, bool bDisablePlayerControl = true); + + /** + * Plays Scene and holds it at the marker called HoldMarker instead of running it to its end. + * + * The sequence plays forward and pauses itself on reaching the marker, and pausing is all that is + * needed to hold a shot: a paused player keeps everything it was driving, so the camera cut stays + * possessed and the fade keeps its value, while the world itself carries on ticking. That is what + * lets a dialogue run on top of a held shot rather than after it. OnSceneHeld is broadcast once the + * hold has been reached; ResumeScene lets the sequence finish. + * + * A marker that is missing, or that sits before the start of the sequence, is reported and the + * scene is played straight through instead. This is deliberate: the player resolves a marker label + * it cannot find to its *current* time, which makes PlayTo run backwards, so an unresolved or + * mistyped label has to be caught here rather than left to rewind the cutscene. + * + * Returns false on the same terms as PlayScene, and also when the sequence could not be read at all. + */ + UFUNCTION(BlueprintCallable, Category = "Eleri|Dialogue") + bool PlaySceneHeld(TSoftObjectPtr Scene, FName HoldMarker, AActor* Instigator = nullptr, bool bDisablePlayerControl = true); + + /** Cuts the scene on screen short. The player gets their controls back the same way they would have + * at the end of it, so nothing downstream can tell a stopped scene from a finished one. */ + UFUNCTION(BlueprintCallable, Category = "Eleri|Dialogue") + void StopScene(); + + /** Pauses the scene on screen where it is. Drives OnSceneHeld the same way a hold point does, which + * is what makes a hold scriptable from a blueprint instead of only from a marker. */ + UFUNCTION(BlueprintCallable, Category = "Eleri|Dialogue") + bool HoldScene(); + + /** Lets a held scene run on to its end. The player gets their controls back when it gets there, by + * the same path a scene that was never held takes. */ + UFUNCTION(BlueprintCallable, Category = "Eleri|Dialogue") + bool ResumeScene(); + + UFUNCTION(BlueprintPure, Category = "Eleri|Dialogue") + bool IsScenePlaying() const { return bScenePlaying; } + + /** True while the scene on screen is paused at a hold point, which is the state a dialogue that was + * started from inside a cutscene is running in. */ + UFUNCTION(BlueprintPure, Category = "Eleri|Dialogue") + bool IsSceneHeld() const { return bSceneHeld; } + + /** The sequence on screen, or null when there is none. */ + UFUNCTION(BlueprintPure, Category = "Eleri|Dialogue") + ULevelSequence* GetActiveScene() const { return ActiveScene; } + + /** Whoever asked for the scene on screen, or null when nobody did */ + UFUNCTION(BlueprintPure, Category = "Eleri|Dialogue") + AActor* GetSceneInstigator() const { return SceneInstigator.Get(); } + +protected: + + /** Both ends of a sequence arrive here, because a scene that runs to its end and a scene that is + * stopped have the same thing to do: hand the player back and announce it. */ + UFUNCTION() + void HandleSceneFinished(); + + UFUNCTION() + void HandleSceneStopped(); + + /** Fired by the sequence player when it pauses, which is how a hold point is noticed: PlayTo asks + * the player to pause itself on the marker frame, so the pause *is* the arrival. Guarded so the + * hold is only ever announced once, because OnPause also fires for a pause nobody asked for. */ + UFUNCTION() + void HandleScenePaused(); + + /** The world the scene was playing in is going away, so there is nobody left to hand the controls + * back to and the tracking is dropped instead of claiming a scene is still running. */ + void HandleWorldCleanup(UWorld* World, bool bSessionEnded, bool bCleanupResources); + +private: + + /** Puts the sequence on screen. Only reached once PlayScene has agreed there is room for one. + * HoldMarker is NAME_None for a scene that simply plays through; anything else has already been + * resolved against the sequence by TryResolveHoldMarker. */ + bool StartScene(ULevelSequence* Scene, FName HoldMarker, AActor* Instigator, bool bDisablePlayerControl); + + /** True when the sequence carries a usable marker of that name, meaning the scene can be held. + * Reports the two ways it cannot be: a missing label, and a label placed before the sequence + * starts, which would have PlayTo unwittingly run the scene backwards. */ + static bool TryResolveHoldMarker(const ULevelSequence* Scene, FName HoldMarker); + + /** The one place a scene ends, whether it ran to its end or was stopped, so that the hand-back and + * the broadcast can neither be missed nor happen twice. */ + void FinishScene(); + + /** Forgets the sequence on screen, letting go of its delegates. The actor and the player are kept: + * the sequence actor is made once and pointed at each new scene, so it outlives any one of them, + * and it goes away with the world it was spawned into. */ + void ResetSceneTracking(); + + /** Movement and looking are taken away from the player for the length of a scene. */ + void TakePlayerControl(); + + /** Gives back exactly what TakePlayerControl took, and only when this subsystem is what took it. */ + void RestorePlayerControl(); + + APlayerController* GetScenePlayerController() const; + UWorld* GetSceneWorld() const; + + /** The sequence on screen, kept so a second request can be told what it collided with and so the + * finished broadcast can name the scene that ended. */ + UPROPERTY() + TObjectPtr ActiveScene = nullptr; + + /** The actor the sequence plays through. Made for the first scene and reused by every one after it, + * so a level does not collect an actor per cutscene the player has watched, and so the actor is + * never taken down from inside the sequence player's own finished callback. */ + UPROPERTY() + TObjectPtr ActiveSequenceActor = nullptr; + + /** Hard, because a scene player is a plain UObject with nothing else holding on to it. */ + UPROPERTY() + TObjectPtr ActivePlayer = nullptr; + + TWeakObjectPtr SceneInstigator; + + bool bScenePlaying = false; + + /** True when the scene on screen was asked to hold, so a pause that arrives is the hold point + * being reached rather than a pause from somewhere else. */ + bool bHoldRequested = false; + + /** True while the scene on screen is paused at its hold point. It is the difference between a + * scene that is waiting to be let go and one that is simply playing. */ + bool bSceneHeld = false; + + /** True only while this subsystem is what holds the player's controls. A scene played with + * bDisablePlayerControl false must not hand back controls it never took, because that would turn + * a player somebody else had locked back on. */ + bool bControlTakenByScene = false; + + FDelegateHandle WorldCleanupHandle; +}; diff --git a/Source/ProjectEleri/DialogueSystem/SpeechDataAsset.h b/Source/ProjectEleri/DialogueSystem/SpeechDataAsset.h index 8d44f2e5..9288a65e 100644 --- a/Source/ProjectEleri/DialogueSystem/SpeechDataAsset.h +++ b/Source/ProjectEleri/DialogueSystem/SpeechDataAsset.h @@ -10,6 +10,7 @@ struct FLineData; class USpeechDataAsset; +class ULevelSequence; UENUM(BlueprintType) enum class EEventRequirementType : uint8 @@ -20,6 +21,23 @@ enum class EEventRequirementType : uint8 Max UMETA(Hidden) }; +/** + * When a dialogue's scene plays. + * + * A scene at the front of a dialogue is the cutscene the conversation is about, and one at the back is + * the beat it ends on. There is no third option, because a scene that wants to run in the middle of a + * conversation is a second dialogue rather than a longer one. + */ +UENUM(BlueprintType) +enum class EDialogueSceneTiming : uint8 +{ + /** Before the first line, so the conversation happens after the cutscene rather than over it */ + OnDialogueStart UMETA(DisplayName = "On Dialogue Start"), + + /** Once the conversation is over and its window has closed */ + OnDialogueEnd UMETA(DisplayName = "On Dialogue End") +}; + USTRUCT(BlueprintType) struct FEventRequirement { @@ -88,6 +106,52 @@ public: UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Eleri|Speech", meta = (Categories = "Quests")) FGameplayTagContainer StartQuests; + /** + * A cutscene this dialogue plays. + * + * It goes through UDialogueSceneSubsystem, which is what holds the player still for the length of + * the sequence and gives the controls back the moment it stops. Leaving it unset makes an ordinary + * conversation, which is why nothing else on this asset depends on it. + */ + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Cutscene") + TSoftObjectPtr Scene; + + /** Whether the scene opens the conversation or closes it */ + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Cutscene", meta = (EditCondition = "!Scene.IsNull()", EditConditionHides)) + EDialogueSceneTiming SceneTiming = EDialogueSceneTiming::OnDialogueStart; + + /** + * True to hold the dialogue until the scene has had its turn, so the lines cannot be read while the + * cutscene is still running. + * + * The turn in question is the scene's hold point when it has one, and its end when it does not: a + * scene with a hold marker is a cutscene the conversation happens *inside* of, and one without is a + * cutscene the conversation happens after. + * + * Only a scene at the front of a dialogue can hold anything: one that closes the conversation has + * nothing left to wait behind. When the scene is refused, because a quest started one of its own in + * the same breath, the dialogue goes ahead rather than waiting for a cutscene that never began. + */ + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Cutscene", meta = (EditCondition = "!Scene.IsNull() && SceneTiming == EDialogueSceneTiming::OnDialogueStart", EditConditionHides)) + bool bHoldDialogueForScene = true; + + /** + * Optional. The name of a marker in Scene for the dialogue to happen at. + * + * When it is set the scene plays only as far as that marker and holds there — the camera cut and + * everything else it drove stay on screen — while the dialogue runs over the top of it. When the + * dialogue ends the scene is let go and plays its tail, which is where the fade out and the release + * of the camera belong. That is what makes one sequence a whole scene rather than an intro. + * + * Leave it empty for a scene that simply plays through before the dialogue starts. + * + * Author it as a Sequencer marker (the yellow ones on the timeline ruler, not a section) with its + * Label set to this name, placed after the first frame. A name that does not match a marker, or one + * placed at the very start, is reported in the log and the scene plays through instead. + */ + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Cutscene", meta = (EditCondition = "!Scene.IsNull() && SceneTiming == EDialogueSceneTiming::OnDialogueStart", EditConditionHides)) + FName SceneHoldMarker; + UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Eleri|Speech") FText PlayerAsk; diff --git a/Source/ProjectEleri/ProjectEleri.Build.cs b/Source/ProjectEleri/ProjectEleri.Build.cs index e4de4b46..efa2745b 100644 --- a/Source/ProjectEleri/ProjectEleri.Build.cs +++ b/Source/ProjectEleri/ProjectEleri.Build.cs @@ -33,7 +33,9 @@ public class ProjectEleri : ModuleRules "GameplayStateTreeModule", "DirectiveUtilitiesRuntime", "StatForge", - "InteractionSystem" + "InteractionSystem", + "LevelSequence", + "MovieScene" }); PrivateDependencyModuleNames.AddRange(new string[] diff --git a/Source/ProjectEleri/Public/UI/MainGameWidget.h b/Source/ProjectEleri/Public/UI/MainGameWidget.h index 500e8c8a..e1c56c33 100644 --- a/Source/ProjectEleri/Public/UI/MainGameWidget.h +++ b/Source/ProjectEleri/Public/UI/MainGameWidget.h @@ -44,6 +44,9 @@ public: UFUNCTION(BlueprintCallable, BlueprintImplementableEvent) void FadeScreenByTime(float FadeTime = 1.f, float BlankScreenTime = 1.f); + + UFUNCTION(BlueprintImplementableEvent) + void ToggleUiForCutscene(bool bActive); UFUNCTION(BlueprintCallable) void TogglePlaceableUI(bool bEnabled); diff --git a/Source/ProjectEleri/QuestSystem/QuestDataAsset.h b/Source/ProjectEleri/QuestSystem/QuestDataAsset.h index 7bb894fc..42692053 100644 --- a/Source/ProjectEleri/QuestSystem/QuestDataAsset.h +++ b/Source/ProjectEleri/QuestSystem/QuestDataAsset.h @@ -9,6 +9,7 @@ #include "QuestDataAsset.generated.h" +class ULevelSequence; class UTexture2D; /** @@ -52,6 +53,22 @@ public: UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Quest", meta = (ClampMin = 0)) int32 DeadlineInDays = 0; + /** + * Cutscenes the quest plays as it changes stage, keyed by the status it moves to. + * + * A stage a quest enters is a status, so this is what makes a quest able to show its own beat + * without any blueprint being involved: the scene under Completed plays the moment the quest is + * handed in, and the one under Failed plays when a deadline runs out, which is what makes failing a + * quest something the player sees rather than something that happens to the journal. + * + * The scenes go through UDialogueSceneSubsystem, which is what holds the player still for the + * length of the sequence and gives the controls back the moment it stops. One scene at a time is + * played, so a quest that changes stage twice in a single frame shows the first scene it asked for + * and stays quiet about the second. + */ + UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Cutscene") + TMap> StatusScenes; + /** The steps, in the order the journal lists them. */ UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Objectives", meta = (TitleProperty = "Description")) TArray Objectives; diff --git a/Source/ProjectEleri/QuestSystem/QuestSubsystem.cpp b/Source/ProjectEleri/QuestSystem/QuestSubsystem.cpp index fca6b151..046d1f99 100644 --- a/Source/ProjectEleri/QuestSystem/QuestSubsystem.cpp +++ b/Source/ProjectEleri/QuestSystem/QuestSubsystem.cpp @@ -14,6 +14,7 @@ #include "InventoryComponent.h" #include "MyCharacter.h" #include "ProjectEleri/Ai/NpcScheduleComponent.h" +#include "ProjectEleri/DialogueSystem/DialogueSceneSubsystem.h" #include "ProjectEleri/DialogueSystem/DialogueSubsystem.h" #include "ProjectEleri/GameEventSystem/GameEventSubsystem.h" #include "ProjectEleri/Items/DataAssets/ItemDataAsset.h" @@ -591,6 +592,33 @@ void UQuestSubsystem::SetQuestStatus(FQuestRuntimeState& State, EQuestStatus New // quest, which can reallocate the map this state lives in. UQuestDataAsset* Quest = State.Quest; OnQuestStatusChanged.Broadcast(Quest, NewStatus); + + // The stage's cutscene comes after the broadcast rather than before it, so a listener that reacts to + // the new status has already reacted by the time the scene takes the player's controls, and so the + // quest asset is not being read while that listener may still be writing to the quest map. + PlayStatusScene(Quest, NewStatus); +} + +void UQuestSubsystem::PlayStatusScene(const UQuestDataAsset* Quest, EQuestStatus NewStatus) +{ + if (Quest == nullptr) + { + return; + } + + const TSoftObjectPtr* Scene = Quest->StatusScenes.Find(NewStatus); + if (Scene == nullptr || Scene->IsNull()) + { + // Most quests have no scene for most stages, which is the ordinary case and not a fault. + return; + } + + if (UDialogueSceneSubsystem* SceneSubsystem = UDialogueSceneSubsystem::Get(this)) + { + // The player goes in as the instigator, because a quest is not an actor and the thing a scene + // listener wants to know is whose scene this is. + SceneSubsystem->PlayScene(*Scene, Cast(GetTestContext())); + } } bool UQuestSubsystem::OfferQuest(FGameplayTag QuestTag) diff --git a/Source/ProjectEleri/QuestSystem/QuestSubsystem.h b/Source/ProjectEleri/QuestSystem/QuestSubsystem.h index cb38ab3f..b5c39efc 100644 --- a/Source/ProjectEleri/QuestSystem/QuestSubsystem.h +++ b/Source/ProjectEleri/QuestSystem/QuestSubsystem.h @@ -245,6 +245,11 @@ protected: /** Moves a quest to a new status and announces it. Does nothing when it is already there. */ void SetQuestStatus(FQuestRuntimeState& State, EQuestStatus NewStatus); + /** Plays the cutscene the quest names for the stage it has just entered, if it names one. Called + * after the status has been announced, so a listener reacting to the stage is already listening + * by the time the scene takes the player's controls away. */ + void PlayStatusScene(const UQuestDataAsset* Quest, EQuestStatus NewStatus); + /** Counts a step up, announcing it and completing it when it reaches what it asked for. Returns * true when something changed. */ bool AdvanceObjective(FQuestRuntimeState& State, FName ObjectiveId, int32 Amount = 1);