Files
Faye/Plugins/SUDS/Source/SUDS/Private/SUDSDialogue.cpp
2026-07-03 19:56:31 +02:00

1380 lines
36 KiB
C++

// Copyright Steve Streeting 2022
// Released under the MIT license https://opensource.org/license/MIT/
#include "SUDSDialogue.h"
#include "SUDSInternal.h"
#include "SUDSLibrary.h"
#include "SUDSParticipant.h"
#include "SUDSScript.h"
#include "SUDSScriptNode.h"
#include "SUDSScriptNodeEvent.h"
#include "SUDSScriptNodeGosub.h"
#include "SUDSScriptNodeSet.h"
#include "SUDSScriptNodeText.h"
#include "SUDSSubsystem.h"
#include "Kismet/GameplayStatics.h"
#include "Sound/DialogueSoundWaveProxy.h"
#include "Sound/DialogueWave.h"
DEFINE_LOG_CATEGORY(LogSUDSDialogue);
const FText USUDSDialogue::DummyText = FText::FromString("INVALID");
const FString USUDSDialogue::DummyString = "INVALID";
FArchive& operator<<(FArchive& Ar, FSUDSDialogueState& Value)
{
Ar << Value.TextNodeID;
Ar << Value.Variables;
Ar << Value.ChoicesTaken;
Ar << Value.ReturnStack;
return Ar;
}
void operator<<(FStructuredArchive::FSlot Slot, FSUDSDialogueState& Value)
{
FStructuredArchive::FRecord Record = Slot.EnterRecord();
Record
<< SA_VALUE(TEXT("TextNodeID"), Value.TextNodeID)
<< SA_VALUE(TEXT("Variables"), Value.Variables)
<< SA_VALUE(TEXT("ChoicesTaken"), Value.ChoicesTaken)
<< SA_VALUE(TEXT("ReturnStack"), Value.ReturnStack);
}
USUDSDialogue::USUDSDialogue(): BaseScript(nullptr),
CurrentSpeakerNode(nullptr),
CurrentRootChoiceNode(nullptr),
bParamNamesExtracted(false),
CurrentSourceLineNo(0)
{
}
void USUDSDialogue::Initialise(const USUDSScript* Script)
{
BaseScript = Script;
CurrentSpeakerNode = nullptr;
InitVariables();
CurrentSpeakerNode = nullptr;
}
void USUDSDialogue::InitVariables()
{
VariableState.Empty();
// Run header nodes immediately (only set nodes)
RunUntilNextSpeakerNodeOrEnd(BaseScript->GetHeaderNode(), false);
}
void USUDSDialogue::Start(FName Label)
{
// Only start if not already on a speaker node
// This makes the restore sequence easier, you don't have to test IsEnded
if (!IsValid(CurrentSpeakerNode))
{
// Note that we don't reset state by default here. This is to allow long-term memory on dialogue, such as
// knowing whether you've met a character before etc.
// We also don't re-run headers here since they will have been run on Initialise()
// This is to allow callers to set variables before Start() that override headers
Restart(false, Label, false);
}
}
void USUDSDialogue::SetParticipants(const TArray<UObject*>& InParticipants)
{
// Protect against null participants
// Since our stable sort dereferences
Participants.Empty();
for (auto P : InParticipants)
{
if (IsValid(P))
{
Participants.AddUnique(P);
}
}
SortParticipants();
}
void USUDSDialogue::AddParticipant(UObject* Participant)
{
if (IsValid(Participant))
{
Participants.AddUnique(Participant);
SortParticipants();
}
}
void USUDSDialogue::SortParticipants()
{
if (!Participants.IsEmpty())
{
// We order by ascending priority so that higher priority values are later in the list
// Which means they're called last and get to override values set by earlier ones
// We'll do a stable sort so that otherwise order is maintained
Participants.StableSort([](const UObject& A, const UObject& B)
{
if (A.Implements<USUDSParticipant>() && B.Implements<USUDSParticipant>())
{
return ISUDSParticipant::Execute_GetDialogueParticipantPriority(&A) <
ISUDSParticipant::Execute_GetDialogueParticipantPriority(&B);
}
// Be deterministic
return &A < &B;
});
}
}
void USUDSDialogue::RunUntilNextSpeakerNodeOrEnd(USUDSScriptNode* NextNode, bool bRaiseAtEnd)
{
// We run through nodes which don't require a speaker line prompt
// E.g. set nodes, select nodes which are all automatically resolved
// Starting with this node
while (NextNode && !IsChoiceOrTextNode(NextNode->GetNodeType()))
{
NextNode = RunNode(NextNode);
}
if (NextNode)
{
if (NextNode->GetNodeType() == ESUDSScriptNodeType::Text)
{
SetCurrentSpeakerNode(Cast<USUDSScriptNodeText>(NextNode), false);
}
else
{
// This can happen if for example user creates a choice node as the first thing
UE_LOG(LogSUDSDialogue,
Error,
TEXT("Error in %s line %d: Tried to run to next speaker node but encountered unexpected node of type %s"),
*BaseScript->GetName(),
NextNode->GetSourceLineNo(),
*(StaticEnum<ESUDSScriptNodeType>()->GetValueAsString(NextNode->GetNodeType()))
);
}
}
else
{
End(!bRaiseAtEnd);
}
}
USUDSScriptNode* USUDSDialogue::RunNode(USUDSScriptNode* Node)
{
CurrentSourceLineNo = Node->GetSourceLineNo();
switch (Node->GetNodeType())
{
case ESUDSScriptNodeType::Select:
return RunSelectNode(Node);
case ESUDSScriptNodeType::SetVariable:
return RunSetVariableNode(Node);
case ESUDSScriptNodeType::Event:
return RunEventNode(Node);
case ESUDSScriptNodeType::Gosub:
return RunGosubNode(Node);
case ESUDSScriptNodeType::Return:
return RunReturnNode(Node);
default: ;
}
UE_LOG(LogSUDSDialogue,
Error,
TEXT("Error in %s line %d: Attempted to run non-runnable node type %s"),
*BaseScript->GetName(),
Node->GetSourceLineNo(),
*(StaticEnum<ESUDSScriptNodeType>()->GetValueAsString(Node->GetNodeType()))
)
return nullptr;
}
USUDSScriptNode* USUDSDialogue::RunSelectNode(USUDSScriptNode* Node)
{
// Define internal random selection variable (used in random selects)
if (Node->IsRandomSelect())
{
// Random picker
// Could try to NOT pick the same ones we already picked, but this would require some additional state, similar
// to "ChoicesTaken" state but for random text nodes already chosen. For now, keep it simple
const int OptCount = Node->GetEdgeCount();
// Use SRand() so can be seeded if required
const int RandChoice = FMath::Min(OptCount-1, FMath::TruncToInt(FMath::SRand() * (float)OptCount));
SetVariableInt(FSUDSConstants::RandomItemSelectIndexVarName, RandChoice);
}
for (auto& Edge : Node->GetEdges())
{
if (Edge.GetCondition().IsValid())
{
// use the first satisfied edge
RaiseExpressionVariablesRequested(Edge.GetCondition(), Edge.GetSourceLineNo());
const bool bSuccess = Edge.GetCondition().EvaluateBoolean(VariableState, GetGlobalVariables(), BaseScript->GetName());
#if WITH_EDITOR
{
FString ExprStr = Edge.GetCondition().GetSourceString();
if (ExprStr.IsEmpty())
{
// Lack of condition is an else / final random option
ExprStr = "else";
}
InternalOnSelectEval.ExecuteIfBound(this, ExprStr, bSuccess, Edge.GetSourceLineNo());
}
#endif
if (bSuccess)
{
return Edge.GetTargetNode().Get();
}
}
}
// NOTE: if no valid path, go to end
// We've already created fall-through else nodes if possible
return nullptr;
}
USUDSScriptNode* USUDSDialogue::RunEventNode(USUDSScriptNode* Node)
{
if (USUDSScriptNodeEvent* EvtNode = Cast<USUDSScriptNodeEvent>(Node))
{
// Build a resolved args list, because we need to evaluate expressions
TArray<FSUDSValue> ArgsResolved;
for (auto& Expr : EvtNode->GetArgs())
{
RaiseExpressionVariablesRequested(Expr, EvtNode->GetSourceLineNo());
ArgsResolved.Add(Expr.Evaluate(VariableState, GetGlobalVariables()));
}
for (const auto& P : Participants)
{
if (P->GetClass()->ImplementsInterface(USUDSParticipant::StaticClass()))
{
ISUDSParticipant::Execute_OnDialogueEvent(P, this, EvtNode->GetEventName(), ArgsResolved);
}
}
OnEvent.Broadcast(this, EvtNode->GetEventName(), ArgsResolved);
#if WITH_EDITOR
InternalOnEvent.ExecuteIfBound(this, EvtNode->GetEventName(), ArgsResolved, EvtNode->GetSourceLineNo());
#endif
}
return GetNextNode(Node);
}
USUDSScriptNode* USUDSDialogue::RunGosubNode(USUDSScriptNode* Node)
{
if (USUDSScriptNodeGosub* GosubNode = Cast<USUDSScriptNodeGosub>(Node))
{
if (auto TargetNode = BaseScript->GetNodeByLabel(GosubNode->GetLabelName()))
{
// Push this gosub node to the return stack, then jump
GosubReturnStack.Push(GosubNode);
return TargetNode;
}
else
{
UE_LOG(LogSUDSDialogue,
Error,
TEXT("Error in %s: Cannot gosub to label '%s', was not found"),
*BaseScript->GetName(),
*GosubNode->GetLabelName().ToString());
}
}
return GetNextNode(Node);
}
USUDSScriptNode* USUDSDialogue::RunReturnNode(USUDSScriptNode* Node)
{
if (GosubReturnStack.Num() > 0)
{
// We return to the next node after the gosub, which temporarily redirected
const auto GoSubNode = GosubReturnStack.Pop();
return GetNextNode(GoSubNode);
}
else
{
UE_LOG(LogSUDSDialogue,
Error,
TEXT("Attempted to return at %s:%d but there was no previous gosub to return to"),
*BaseScript->GetName(),
Node->GetSourceLineNo());
return nullptr;
}
}
USUDSScriptNode* USUDSDialogue::RunSetVariableNode(USUDSScriptNode* Node)
{
if (USUDSScriptNodeSet* SetNode = Cast<USUDSScriptNodeSet>(Node))
{
if (SetNode->GetExpression().IsValid())
{
RaiseExpressionVariablesRequested(SetNode->GetExpression(), SetNode->GetSourceLineNo());
FSUDSValue Value = SetNode->GetExpression().Evaluate(VariableState, GetGlobalVariables());
FName Identifier;
if (USUDSLibrary::IsDialogueVariableGlobal(SetNode->GetIdentifier(), Identifier))
{
InternalSetGlobalVariable(this->GetWorld(), Identifier, Value, true, BaseScript->GetName(), SetNode->GetSourceLineNo());
}
else
{
SetVariableImpl(SetNode->GetIdentifier(), Value, true, SetNode->GetSourceLineNo());
}
#if WITH_EDITOR
// We do this here so that we have access to the expression
InternalOnSetVar.ExecuteIfBound(this,
SetNode->GetIdentifier(),
Value,
SetNode->GetExpression().IsLiteral()
? ""
: SetNode->GetExpression().GetSourceString(),
SetNode->GetSourceLineNo());
#endif
}
}
// Always one edge
return GetNextNode(Node);
}
void USUDSDialogue::RaiseVariableChange(const FName& VarName, const FSUDSValue& Value, bool bFromScript, int LineNo)
{
for (const auto& P : Participants)
{
if (P->GetClass()->ImplementsInterface(USUDSParticipant::StaticClass()))
{
ISUDSParticipant::Execute_OnDialogueVariableChanged(P, this, VarName, Value, bFromScript);
}
}
OnVariableChanged.Broadcast(this, VarName, Value, bFromScript);
#if WITH_EDITOR
if (!bFromScript)
{
// Script setting is raised in SetNode so we have access to expressions
InternalOnSetVarByCode.ExecuteIfBound(this, VarName, Value);
}
#endif
}
void USUDSDialogue::RaiseVariableRequested(const FName& VarName, int LineNo)
{
// Because variables set by participants should "win", raise event first
OnVariableRequested.Broadcast(this, VarName);
for (const auto& P : Participants)
{
if (P->GetClass()->ImplementsInterface(USUDSParticipant::StaticClass()))
{
ISUDSParticipant::Execute_OnDialogueVariableRequested(P, this, VarName);
}
}
}
void USUDSDialogue::RaiseExpressionVariablesRequested(const FSUDSExpression& Expression, int LineNo)
{
for (auto& Var : Expression.GetVariableNames())
{
RaiseVariableRequested(Var, LineNo);
}
}
const TMap<FName, FSUDSValue>& USUDSDialogue::GetGlobalVariables() const
{
return InternalGetGlobalVariables(this->GetWorld());
}
void USUDSDialogue::SetCurrentSpeakerNode(USUDSScriptNodeText* Node, bool bQuietly)
{
CurrentSpeakerNode = Node;
CurrentSpeakerDisplayName = FText::GetEmpty();
bParamNamesExtracted = false;
if (Node)
{
CurrentSourceLineNo = Node->GetSourceLineNo();
}
else
{
CurrentSourceLineNo = 0;
}
UpdateChoices();
if (!bQuietly)
{
if (CurrentSpeakerNode)
RaiseNewSpeakerLine();
else
RaiseFinished();
}
}
FText USUDSDialogue::ResolveParameterisedText(const TArray<FName> Params, const FTextFormat& TextFormat, int LineNo)
{
for (const auto& P : Params)
{
RaiseVariableRequested(P, LineNo);
}
// Need to make a temp arg list for compatibility
// Also lets us just set the ones we need to
FFormatNamedArguments Args;
GetTextFormatArgs(Params, Args);
return FText::Format(TextFormat, Args);
}
void USUDSDialogue::GetTextFormatArgs(const TArray<FName>& ArgNames, FFormatNamedArguments& OutArgs) const
{
for (auto& Name : ArgNames)
{
FName GlobalName;
if (USUDSLibrary::IsDialogueVariableGlobal(Name, GlobalName))
{
auto& Globals = InternalGetGlobalVariables(this->GetWorld());
if (const FSUDSValue* Value = Globals.Find(GlobalName))
{
// Add to format args using name with prefix
OutArgs.Add(Name.ToString(), Value->ToFormatArg());
}
}
else if (const FSUDSValue* Value = VariableState.Find(Name))
{
// Use the operator conversion
OutArgs.Add(Name.ToString(), Value->ToFormatArg());
}
}
}
FText USUDSDialogue::GetText()
{
if (CurrentSpeakerNode)
{
if (CurrentSpeakerNode->HasParameters())
{
return ResolveParameterisedText(CurrentSpeakerNode->GetParameterNames(),
CurrentSpeakerNode->GetTextFormat(),
CurrentSpeakerNode->GetSourceLineNo());
}
else
{
return CurrentSpeakerNode->GetText();
}
}
return DummyText;
}
UDialogueWave* USUDSDialogue::GetWave() const
{
if (CurrentSpeakerNode)
{
return CurrentSpeakerNode->GetWave();
}
return nullptr;
}
bool USUDSDialogue::IsCurrentLineVoiced() const
{
if (CurrentSpeakerNode)
{
return IsValid(CurrentSpeakerNode->GetWave());
}
return false;
}
const FString& USUDSDialogue::GetSpeakerID() const
{
if (CurrentSpeakerNode)
return CurrentSpeakerNode->GetSpeakerID();
return DummyString;
}
FText USUDSDialogue::GetSpeakerDisplayName() const
{
if (CurrentSpeakerDisplayName.IsEmpty())
{
// Derive speaker display name
// Is just a special variable "SpeakerName.SpeakerID"
// or just the SpeakerID if none specified
static const FString SpeakerIDPrefix = "SpeakerName.";
FName Key(SpeakerIDPrefix + GetSpeakerID());
if (auto Arg = VariableState.Find(Key))
{
if (Arg->GetType() == ESUDSValueType::Text)
{
CurrentSpeakerDisplayName = Arg->GetTextValue();
}
else
{
UE_LOG(LogSUDSDialogue,
Error,
TEXT("Error in %s: %s was set to a value that was not text, cannot use"),
*BaseScript->GetName(),
*Key.ToString());
}
}
if (CurrentSpeakerDisplayName.IsEmpty())
{
// If no display name was specified, use the (non-localised) speaker ID
CurrentSpeakerDisplayName = FText::FromString(GetSpeakerID());
}
}
return CurrentSpeakerDisplayName;
}
UDialogueVoice* USUDSDialogue::GetSpeakerVoice() const
{
if (CurrentSpeakerNode)
{
return GetVoice(CurrentSpeakerNode->GetSpeakerID());
}
return nullptr;
}
UDialogueVoice* USUDSDialogue::GetVoice(FString Name) const
{
return BaseScript->GetSpeakerVoice(Name);
}
UDialogueVoice* USUDSDialogue::GetTargetVoice() const
{
if (CurrentSpeakerNode)
{
// Assume that target is the first party that's NOT speaking
for (auto& Name : BaseScript->GetSpeakers())
{
if (Name != CurrentSpeakerNode->GetSpeakerID())
{
return BaseScript->GetSpeakerVoice(Name);
}
}
}
return nullptr;
}
USoundBase* USUDSDialogue::GetSoundForCurrentLine(bool bAllowAnyTarget) const
{
// UDialogueWave's contexts have both speakers and targets, but the GetWaveFromContext method is too restrictive
// Instead we'll search the contexts ourselves and be more fuzzy
if (auto Wave = GetWave())
{
auto SpeakerVoice = GetSpeakerVoice();
auto TargetVoice = GetTargetVoice();
for (auto& Ctx : Wave->ContextMappings)
{
// Match specific target voice first, and unspecified targets
if (Ctx.Context.Speaker == SpeakerVoice)
{
if (Ctx.Context.Targets.Contains(TargetVoice))
{
// Need to use the proxy according to DialogueWave
return Ctx.Proxy;
}
}
}
// If we got here, match more leniently
if (bAllowAnyTarget)
{
for (auto& Ctx : Wave->ContextMappings)
{
// Match specific target voice first, and unspecified targets
if (Ctx.Context.Speaker == SpeakerVoice)
{
// Need to use the proxy according to DialogueWave
return Ctx.Proxy;
}
}
}
}
return nullptr;
}
USoundConcurrency* USUDSDialogue::GetVoiceSoundConcurrency() const
{
return GetSUDSSubsystem(this->GetWorld())->GetVoicedLineConcurrency();
}
void USUDSDialogue::PlayVoicedLine2D(float VolumeMultiplier, float PitchMultiplier, bool bLooselyMatchTarget)
{
if (auto Sound = GetSoundForCurrentLine(bLooselyMatchTarget))
{
UGameplayStatics::PlaySound2D(this, Sound, VolumeMultiplier, PitchMultiplier, 0, GetVoiceSoundConcurrency());
}
}
void USUDSDialogue::PlayVoicedLineAtLocation(FVector Location,
FRotator Rotation,
float VolumeMultiplier,
float PitchMultiplier,
USoundAttenuation* AttenuationSettings, bool bLooselyMatchTarget)
{
if (auto Sound = GetSoundForCurrentLine(bLooselyMatchTarget))
{
UGameplayStatics::PlaySoundAtLocation(this,
Sound,
Location,
Rotation,
VolumeMultiplier,
PitchMultiplier,
0,
AttenuationSettings,
GetVoiceSoundConcurrency());
}
}
UAudioComponent* USUDSDialogue::SpawnVoicedLine2D(float VolumeMultiplier, float PitchMultiplier, bool bLooselyMatchTarget)
{
if (auto Sound = GetSoundForCurrentLine(bLooselyMatchTarget))
{
return UGameplayStatics::SpawnSound2D(this,
Sound,
VolumeMultiplier,
PitchMultiplier,
0,
GetVoiceSoundConcurrency());
}
return nullptr;
}
UAudioComponent* USUDSDialogue::SpawnVoicedLineAtLocation(FVector Location,
FRotator Rotation,
float VolumeMultiplier,
float PitchMultiplier,
USoundAttenuation* AttenuationSettings,
bool bLooselyMatchTarget)
{
if (auto Sound = GetSoundForCurrentLine(bLooselyMatchTarget))
{
return UGameplayStatics::SpawnSoundAtLocation(this,
Sound,
Location,
Rotation,
VolumeMultiplier,
PitchMultiplier,
0,
AttenuationSettings,
GetVoiceSoundConcurrency());
}
return nullptr;
}
USoundBase* USUDSDialogue::GetVoicedLineSound(bool bLooselyMatchTarget)
{
return GetSoundForCurrentLine(bLooselyMatchTarget);
}
USUDSScriptNode* USUDSDialogue::GetNextNode(USUDSScriptNode* Node)
{
// In the case of select or random, we need to evaluate to get the next node
if (Node->GetNodeType() == ESUDSScriptNodeType::Select)
{
return RunSelectNode(Node);
}
else
{
return BaseScript->GetNextNode(Node);
}
}
bool USUDSDialogue::IsChoiceOrTextNode(ESUDSScriptNodeType Type)
{
return Type == ESUDSScriptNodeType::Text || Type == ESUDSScriptNodeType::Choice;
}
const USUDSScriptNode* USUDSDialogue::WalkToNextChoiceNode(USUDSScriptNode* FromNode, bool bExecute)
{
if (FromNode && FromNode->GetEdgeCount() == 1)
{
const auto NextNode = GetNextNode(FromNode);
TArray<TObjectPtr<USUDSScriptNodeGosub>> TempGosubStack;
if (!bExecute)
{
// Make a copy of the gosub stack so we can safely explore gosubs
TempGosubStack.Append(GosubReturnStack);
}
const auto ResultNode = RecurseWalkToNextChoiceOrTextNode(NextNode, bExecute, bExecute ? GosubReturnStack : TempGosubStack);
if (ResultNode && ResultNode->GetNodeType() == ESUDSScriptNodeType::Choice)
{
return ResultNode;
}
}
return nullptr;
}
USUDSScriptNode* USUDSDialogue::RecurseWalkToNextChoiceOrTextNode(USUDSScriptNode* Node, bool bExecute, TArray<TObjectPtr<USUDSScriptNodeGosub>>& LocalGosubStack)
{
auto NextNode = Node;
while (NextNode && !IsChoiceOrTextNode(NextNode->GetNodeType()))
{
// Special case gosub/return in non-execute mode, since only RunNode will explore them
if (!bExecute)
{
if (NextNode->GetNodeType() == ESUDSScriptNodeType::Gosub)
{
// We need to special case Gosubs, since to find the choice we have to go into them and potentially out again
if (USUDSScriptNodeGosub* GosubNode = Cast<USUDSScriptNodeGosub>(NextNode))
{
if (auto SubNode = BaseScript->GetNodeByLabel(GosubNode->GetLabelName()))
{
LocalGosubStack.Add(GosubNode);
NextNode = RecurseWalkToNextChoiceOrTextNode(SubNode, bExecute, LocalGosubStack);
continue;
}
}
}
else if (NextNode->GetNodeType() == ESUDSScriptNodeType::Return)
{
if (LocalGosubStack.Num() > 0)
{
// We try to find the next choice node after the gosub, which temporarily redirected
const auto GoSubNode = LocalGosubStack.Pop();
NextNode = RecurseWalkToNextChoiceOrTextNode(GetNextNode(GoSubNode), bExecute, LocalGosubStack);
continue;
}
else
{
return nullptr;
}
}
}
if (bExecute)
{
NextNode = RunNode(NextNode);
}
else
{
NextNode = GetNextNode(NextNode);
}
}
return NextNode;
}
const USUDSScriptNode* USUDSDialogue::RunUntilNextChoiceNode(USUDSScriptNode* FromNode)
{
return WalkToNextChoiceNode(FromNode, true);
}
const USUDSScriptNode* USUDSDialogue::FindNextChoiceNode(USUDSScriptNode* FromNode)
{
return WalkToNextChoiceNode(FromNode, false);
}
const TArray<FSUDSScriptEdge>& USUDSDialogue::GetChoices() const
{
return CurrentChoices;
}
void USUDSDialogue::RecurseAppendChoices(const USUDSScriptNode* Node, TArray<FSUDSScriptEdge>& OutChoices)
{
if (!Node)
return;
// We only cascade into choices or selects
if(Node->GetNodeType() != ESUDSScriptNodeType::Choice &&
Node->GetNodeType() != ESUDSScriptNodeType::Select)
{
return;
}
for (auto& Edge : Node->GetEdges())
{
switch (Edge.GetType())
{
case ESUDSEdgeType::Decision:
OutChoices.Add(Edge);
break;
case ESUDSEdgeType::Condition:
// Conditional edges are under selects
if (Edge.GetCondition().IsValid())
{
RaiseExpressionVariablesRequested(Edge.GetCondition(), Edge.GetSourceLineNo());
if (Edge.GetCondition().EvaluateBoolean(VariableState, GetGlobalVariables(), BaseScript->GetName()))
{
RecurseAppendChoices(Edge.GetTargetNode().Get(), OutChoices);
// When we choose a path on a select, we don't check the other paths, we can only go down one
return;
}
}
break;
case ESUDSEdgeType::Chained:
RecurseAppendChoices(Edge.GetTargetNode().Get(), OutChoices);
break;
default:
case ESUDSEdgeType::Continue:
UE_LOG(LogSUDSDialogue, Fatal, TEXT("Should not have encountered invalid edge in RecurseAppendChoices"))
break;
};
}
}
void USUDSDialogue::UpdateChoices()
{
CurrentChoices.Reset();
CurrentRootChoiceNode = nullptr;
if (CurrentSpeakerNode)
{
// If we've either found choices through static checking (on one or other select paths), we look for them now
// We also check if we're inside a gosub, since the call site changes whether there may be choices or not
if (CurrentSpeakerNode->MayHaveChoices() ||
GosubReturnStack.Num() > 0)
{
// We MIGHT have a choice; conditionals can result in HasChoices() being true but the current state not actually
// taking us to a choice path
CurrentRootChoiceNode = FindNextChoiceNode(CurrentSpeakerNode);
if (CurrentRootChoiceNode)
{
// Run any e.g. set nodes between text and choice
// These can be set nodes directly under the text and before the first choice, which get run for all choices
RunUntilNextChoiceNode(CurrentSpeakerNode);
// Once we've found & run up to the root choice, there can be potentially a tree of mixed choice/select nodes
// for supporting conditional choices
RecurseAppendChoices(CurrentRootChoiceNode, CurrentChoices);
}
}
if (CurrentChoices.Num() == 0)
{
if (auto Edge = CurrentSpeakerNode->GetEdge(0))
{
// Simple no-choice progression
// May occur if HasChoices was true but in current state no choice was found
CurrentChoices.Add(*Edge);
}
}
}
}
int USUDSDialogue::GetNumberOfChoices() const
{
return CurrentChoices.Num();
}
bool USUDSDialogue::IsSimpleContinue() const
{
return CurrentChoices.Num() == 1 && CurrentChoices[0].GetText().IsEmpty();
}
FText USUDSDialogue::GetChoiceText(int Index)
{
if (CurrentChoices.IsValidIndex(Index))
{
auto& Choice = CurrentChoices[Index];
if (Choice.HasParameters())
{
return ResolveParameterisedText(Choice.GetParameterNames(), Choice.GetTextFormat(), Choice.GetSourceLineNo());
}
else
{
return Choice.GetText();
}
}
else
{
UE_LOG(LogSUDSDialogue, Error, TEXT("Invalid choice index %d on node %s"), Index, *GetText().ToString());
}
return DummyText;
}
bool USUDSDialogue::HasChoiceIndexBeenTakenPreviously(int Index)
{
if (CurrentChoices.IsValidIndex(Index))
{
return HasChoiceBeenTakenPreviously(CurrentChoices[Index]);
}
return false;
}
bool USUDSDialogue::HasChoiceBeenTakenPreviously(const FSUDSScriptEdge& Choice)
{
return ChoicesTaken.Contains(Choice.GetTextID());
}
bool USUDSDialogue::Continue()
{
if (GetNumberOfChoices() == 1)
{
return Choose(0);
}
return !IsEnded();
}
bool USUDSDialogue::Choose(int Index)
{
if (CurrentChoices.IsValidIndex(Index))
{
// ONLY run to choice node if there is one!
// This method is called for Continue() too, which has no choice node
if (CurrentNodeHasChoices())
{
const auto& Choice = CurrentChoices[Index];
ChoicesTaken.Add(Choice.GetTextID());
RaiseChoiceMade(Index, Choice.GetSourceLineNo());
RaiseProceeding();
}
else
{
RaiseProceeding();
}
// Then choose path
RunUntilNextSpeakerNodeOrEnd(CurrentChoices[Index].GetTargetNode().Get(), true);
return !IsEnded();
}
else
{
UE_LOG(LogSUDSDialogue, Error, TEXT("Invalid choice index %d on node %s"), Index, *GetText().ToString());
}
return false;
}
bool USUDSDialogue::CurrentNodeHasChoices() const
{
return CurrentRootChoiceNode != nullptr;
}
bool USUDSDialogue::IsEnded() const
{
return CurrentSpeakerNode == nullptr;
}
bool USUDSDialogue::IsFinalLine() const
{
return CurrentSpeakerNode && CurrentChoices.Num() == 1 && CurrentChoices[0].GetTargetNode() == nullptr;
}
void USUDSDialogue::End(bool bQuietly)
{
SetCurrentSpeakerNode(nullptr, bQuietly);
}
int USUDSDialogue::GetCurrentSourceLine() const
{
return CurrentSourceLineNo;
}
void USUDSDialogue::ResetState(bool bResetVariables, bool bResetPosition, bool bResetVisited)
{
if (bResetVariables)
InitVariables();
if (bResetPosition)
SetCurrentSpeakerNode(nullptr, true);
if (bResetVisited)
ChoicesTaken.Reset();
}
FSUDSDialogueState USUDSDialogue::GetSavedState() const
{
const FString CurrentNodeId = CurrentSpeakerNode
? SUDS_GET_TEXT_KEY(CurrentSpeakerNode->GetText())
: FString();
TArray<FString> ExportReturnStack;
for (auto Node : GosubReturnStack)
{
if (auto GN = Cast<USUDSScriptNodeGosub>(Node))
{
ExportReturnStack.Add(GN->GetGosubID());
}
}
return FSUDSDialogueState(CurrentNodeId, VariableState, ChoicesTaken, ExportReturnStack);
}
void USUDSDialogue::RestoreSavedState(const FSUDSDialogueState& State)
{
// Don't just empty variables
// Re-run init to ensure header state is initialised then merge; important for it script is altered since state saved
InitVariables();
VariableState.Append(State.GetVariables());
ChoicesTaken.Empty();
ChoicesTaken.Append(State.GetChoicesTaken());
GosubReturnStack.Empty();
for (auto ID : State.GetReturnStack())
{
USUDSScriptNodeGosub* Node = BaseScript->GetNodeByGosubID(ID);
if (!Node)
{
UE_LOG(LogSUDSDialogue, Error, TEXT("Restore: Can't find Gosub with ID %s, returns referencing it will go to end"), *ID);
}
// Add anyway, will just go to end
GosubReturnStack.Add(Node);
}
// If not found this will be null
if (!State.GetTextNodeID().IsEmpty())
{
USUDSScriptNodeText* Node = BaseScript->GetNodeByTextID(State.GetTextNodeID());
SetCurrentSpeakerNode(Node, true);
}
else
{
SetCurrentSpeakerNode(nullptr, true);
}
}
void USUDSDialogue::Restart(bool bResetState, FName StartLabel, bool bReRunHeader)
{
if (bResetState)
{
ResetState();
}
// Always reset return stack
GosubReturnStack.Empty();
CurrentSourceLineNo = 0;
RaiseStarting(StartLabel);
if (!bResetState && bReRunHeader)
{
// Run header nodes but don't re-init
RunUntilNextSpeakerNodeOrEnd(BaseScript->GetHeaderNode(), false);
}
if (StartLabel != NAME_None)
{
// Check that StartLabel leads to a text node
// Labels can lead to choices or select nodes for looping, but there has to be a text node to start with.
auto StartNode = BaseScript->GetNodeByLabel(StartLabel);
if (!StartNode)
{
UE_LOG(LogSUDSDialogue, Error, TEXT("No start label called %s in dialogue %s"), *StartLabel.ToString(), *BaseScript->GetName());
StartNode = BaseScript->GetFirstNode();
}
else if (StartNode->GetNodeType() == ESUDSScriptNodeType::Choice)
{
UE_LOG(LogSUDSDialogue,
Error,
TEXT("Label %s in dialogue %s cannot be used as a start point, points to a choice."),
*StartLabel.ToString(),
*BaseScript->GetName());
StartNode = BaseScript->GetFirstNode();
}
RunUntilNextSpeakerNodeOrEnd(StartNode, true);
}
else
{
RunUntilNextSpeakerNodeOrEnd(BaseScript->GetFirstNode(), true);
}
}
TSet<FName> USUDSDialogue::GetParametersInUse()
{
// Build on demand, may not be needed
if (!bParamNamesExtracted)
{
CurrentRequestedParamNames.Reset();
if (CurrentSpeakerNode && CurrentSpeakerNode->HasParameters())
{
CurrentRequestedParamNames.Append(CurrentSpeakerNode->GetParameterNames());
}
for (auto& Choice : CurrentChoices)
{
if (Choice.HasParameters())
{
CurrentRequestedParamNames.Append(Choice.GetParameterNames());
}
}
bParamNamesExtracted = true;
}
return CurrentRequestedParamNames;
}
void USUDSDialogue::RaiseStarting(FName StartLabel)
{
for (const auto& P : Participants)
{
if (P->GetClass()->ImplementsInterface(USUDSParticipant::StaticClass()))
{
ISUDSParticipant::Execute_OnDialogueStarting(P, this, StartLabel);
}
}
OnStarting.Broadcast(this, StartLabel);
#if WITH_EDITOR
InternalOnStarting.ExecuteIfBound(this, StartLabel);
#endif
}
void USUDSDialogue::RaiseFinished()
{
for (const auto& P : Participants)
{
if (P->GetClass()->ImplementsInterface(USUDSParticipant::StaticClass()))
{
ISUDSParticipant::Execute_OnDialogueFinished(P, this);
}
}
OnFinished.Broadcast(this);
#if WITH_EDITOR
InternalOnFinished.ExecuteIfBound(this);
#endif
}
void USUDSDialogue::RaiseNewSpeakerLine()
{
for (const auto& P : Participants)
{
if (P->GetClass()->ImplementsInterface(USUDSParticipant::StaticClass()))
{
ISUDSParticipant::Execute_OnDialogueSpeakerLine(P, this);
}
}
// Event listeners get it after
OnSpeakerLine.Broadcast(this);
#if WITH_EDITOR
InternalOnSpeakerLine.ExecuteIfBound(this, GetCurrentSourceLine());
#endif
}
void USUDSDialogue::RaiseChoiceMade(int Index, int LineNo)
{
for (const auto& P : Participants)
{
if (P->GetClass()->ImplementsInterface(USUDSParticipant::StaticClass()))
{
ISUDSParticipant::Execute_OnDialogueChoiceMade(P, this, Index);
}
}
// Event listeners get it after
OnChoice.Broadcast(this, Index);
#if WITH_EDITOR
InternalOnChoice.ExecuteIfBound(this, Index, LineNo);
#endif
}
void USUDSDialogue::RaiseProceeding()
{
for (const auto& P : Participants)
{
if (P->GetClass()->ImplementsInterface(USUDSParticipant::StaticClass()))
{
ISUDSParticipant::Execute_OnDialogueProceeding(P, this);
}
}
// Event listeners get it after
OnProceeding.Broadcast(this);
#if WITH_EDITOR
InternalOnProceeding.ExecuteIfBound(this);
#endif
}
FText USUDSDialogue::GetVariableText(FName Name) const
{
if (const auto Arg = VariableState.Find(Name))
{
if (Arg->GetType() == ESUDSValueType::Text)
{
return Arg->GetTextValue();
}
else
{
UE_LOG(LogSUDSDialogue, Error, TEXT("Requested variable %s of type text but was not a compatible type"), *Name.ToString());
}
}
return FText();
}
void USUDSDialogue::SetVariableInt(FName Name, int32 Value)
{
SetVariable(Name, Value);
}
int USUDSDialogue::GetVariableInt(FName Name) const
{
if (const auto Arg = VariableState.Find(Name))
{
switch (Arg->GetType())
{
case ESUDSValueType::Int:
return Arg->GetIntValue();
case ESUDSValueType::Float:
UE_LOG(LogSUDSDialogue, Warning, TEXT("Casting variable %s to int, data loss may occur"), *Name.ToString());
return Arg->GetFloatValue();
default:
case ESUDSValueType::Gender:
case ESUDSValueType::Text:
UE_LOG(LogSUDSDialogue, Error, TEXT("Variable %s is not a compatible integer type"), *Name.ToString());
}
}
return 0;
}
void USUDSDialogue::SetVariableFloat(FName Name, float Value)
{
SetVariable(Name, Value);
}
float USUDSDialogue::GetVariableFloat(FName Name) const
{
if (const auto Arg = VariableState.Find(Name))
{
switch (Arg->GetType())
{
case ESUDSValueType::Int:
return Arg->GetIntValue();
case ESUDSValueType::Float:
return Arg->GetFloatValue();
default:
case ESUDSValueType::Gender:
case ESUDSValueType::Text:
UE_LOG(LogSUDSDialogue, Error, TEXT("Variable %s is not a compatible float type"), *Name.ToString());
}
}
return 0;
}
void USUDSDialogue::SetVariableGender(FName Name, ETextGender Value)
{
SetVariable(Name, Value);
}
ETextGender USUDSDialogue::GetVariableGender(FName Name) const
{
if (const auto Arg = VariableState.Find(Name))
{
switch (Arg->GetType())
{
case ESUDSValueType::Gender:
return Arg->GetGenderValue();
default:
case ESUDSValueType::Int:
case ESUDSValueType::Float:
case ESUDSValueType::Text:
UE_LOG(LogSUDSDialogue, Error, TEXT("Variable %s is not a compatible gender type"), *Name.ToString());
}
}
return ETextGender::Neuter;
}
void USUDSDialogue::SetVariableBoolean(FName Name, bool Value)
{
// Use explicit FSUDSValue constructor to avoid default int conversion
SetVariable(Name, FSUDSValue(Value));
}
bool USUDSDialogue::GetVariableBoolean(FName Name) const
{
if (const auto Arg = VariableState.Find(Name))
{
switch (Arg->GetType())
{
case ESUDSValueType::Boolean:
return Arg->GetBooleanValue();
case ESUDSValueType::Int:
return Arg->GetIntValue() != 0;
default:
case ESUDSValueType::Float:
case ESUDSValueType::Gender:
case ESUDSValueType::Text:
UE_LOG(LogSUDSDialogue, Error, TEXT("Variable %s is not a compatible boolean type"), *Name.ToString());
}
}
return false;
}
void USUDSDialogue::SetVariableName(FName Name, FName Value)
{
SetVariable(Name, FSUDSValue(Value, false));
}
FName USUDSDialogue::GetVariableName(FName Name) const
{
if (const auto Arg = VariableState.Find(Name))
{
if (Arg->GetType() == ESUDSValueType::Name)
{
return Arg->GetNameValue();
}
else
{
UE_LOG(LogSUDSDialogue, Error, TEXT("Requested variable %s of type text but was not a compatible type"), *Name.ToString());
}
}
return NAME_None;
}
void USUDSDialogue::UnSetVariable(FName Name)
{
VariableState.Remove(Name);
}
FSUDSValue USUDSDialogue::GetSpeakerLineUserMetadata(FName Key) const
{
if (CurrentSpeakerNode)
{
if (auto pExpr = CurrentSpeakerNode->GetUserMetadata().Find(Key))
{
return pExpr->Evaluate(VariableState, GetGlobalVariables());
}
}
return FSUDSValue();
}
TMap<FName, FSUDSValue> USUDSDialogue::GetAllSpeakerLineUserMetadata() const
{
TMap<FName, FSUDSValue> Ret;
if (CurrentSpeakerNode)
{
const TMap<FName, FSUDSValue>& GlobalVariables = GetGlobalVariables();
const auto& InMeta = CurrentSpeakerNode->GetUserMetadata();
for (const auto& Pair : InMeta)
{
Ret.Add(Pair.Key, Pair.Value.Evaluate(VariableState, GlobalVariables));
}
}
return Ret;
}
FSUDSValue USUDSDialogue::GetChoiceUserMetadata(int Index, FName Key) const
{
if (CurrentChoices.IsValidIndex(Index))
{
if (auto pExpr = CurrentChoices[Index].GetUserMetadata().Find(Key))
{
return pExpr->Evaluate(VariableState, GetGlobalVariables());
}
}
return FSUDSValue();
}
TMap<FName, FSUDSValue> USUDSDialogue::GetAllChoiceUserMetadata(int Index) const
{
TMap<FName, FSUDSValue> Ret;
if (CurrentChoices.IsValidIndex(Index))
{
const TMap<FName, FSUDSValue>& GlobalVariables = GetGlobalVariables();
const auto& InMeta = CurrentChoices[Index].GetUserMetadata();
for (const auto& Pair : InMeta)
{
Ret.Add(Pair.Key, Pair.Value.Evaluate(VariableState, GlobalVariables));
}
}
return Ret;
}