Simple cutscene manager and a test cutscene

This commit is contained in:
2026-10-17 00:42:47 +02:00
parent c7c6a014f3
commit 81af1b0214
14 changed files with 1025 additions and 8 deletions

Binary file not shown.

Binary file not shown.

View File

@@ -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<AEleriPlayerController>(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<USpeechDataAsset>
}
void UDialogueComponent::TriggerDialogue(USpeechDataAsset* DialogueAssetPath, const TMap<FString, FText>& 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<FString, FText>& 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<FText> 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<FString, FText> 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

View File

@@ -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<FText>& 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<FString, FText>& 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<FText> GetPlayerAsksFromDialogueList(const TArray<USpeechDataAsset*>& DialogueList);
static TArray<FText> GetDialogueOptionsFromPlayerSpeakList(const TArray<FPlayerSpeakToDialogue>& SpeakList);
@@ -78,11 +122,27 @@ protected:
TMap<int32, FPlayerSpeakToDialogue> MappedDialogueOptions;
UPROPERTY(EditAnywhere, BlueprintReadWrite)
TMap<FString, FText> 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<USpeechDataAsset> PendingDialogue = nullptr;
UPROPERTY()
TMap<FString, FText> 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();

View File

@@ -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<UDialogueSceneSubsystem>() : 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<ULevelSequence> 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("<unknown>"));
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<ULevelSequence> 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("<unknown>"));
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("<none>"));
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<FFrameNumber> 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<AEleriPlayerController>(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<AEleriPlayerController>(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();
}

View File

@@ -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<ULevelSequence> 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<ULevelSequence> 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<ULevelSequence> 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<ALevelSequenceActor> ActiveSequenceActor = nullptr;
/** Hard, because a scene player is a plain UObject with nothing else holding on to it. */
UPROPERTY()
TObjectPtr<ULevelSequencePlayer> ActivePlayer = nullptr;
TWeakObjectPtr<AActor> 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;
};

View File

@@ -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<ULevelSequence> 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;

View File

@@ -33,7 +33,9 @@ public class ProjectEleri : ModuleRules
"GameplayStateTreeModule",
"DirectiveUtilitiesRuntime",
"StatForge",
"InteractionSystem"
"InteractionSystem",
"LevelSequence",
"MovieScene"
});
PrivateDependencyModuleNames.AddRange(new string[]

View File

@@ -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);

View File

@@ -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<EQuestStatus, TSoftObjectPtr<ULevelSequence>> StatusScenes;
/** The steps, in the order the journal lists them. */
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Objectives", meta = (TitleProperty = "Description"))
TArray<FQuestObjective> Objectives;

View File

@@ -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<ULevelSequence>* 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<AActor>(GetTestContext()));
}
}
bool UQuestSubsystem::OfferQuest(FGameplayTag QuestTag)

View File

@@ -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);