Initial push to repo

This commit is contained in:
2026-07-03 19:56:31 +02:00
commit 4cf4176d57
1305 changed files with 43455 additions and 0 deletions

View File

@@ -0,0 +1,21 @@
// Copyright Steve Streeting 2022
// Released under the MIT license https://opensource.org/license/MIT/
#include "SUDS.h"
#include "Modules/ModuleManager.h"
#define LOCTEXT_NAMESPACE "FSUDSModule"
void FSUDSModule::StartupModule()
{
// This code will execute after your module is loaded into memory; the exact timing is specified in the .uplugin file per-module
}
void FSUDSModule::ShutdownModule()
{
// This function may be called during shutdown to clean up your module. For modules that support dynamic reloading,
// we call this function before unloading the module.
}
#undef LOCTEXT_NAMESPACE
IMPLEMENT_MODULE(FSUDSModule, SUDS)

View File

@@ -0,0 +1,4 @@
#include "SUDSCommon.h"
const FName FSUDSConstants::RandomItemSelectIndexVarName(SUDS_RANDOMITEM_VAR);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,465 @@
// Copyright Steve Streeting 2022
// Released under the MIT license https://opensource.org/license/MIT/
#include "SUDSExpression.h"
#include "SUDSLibrary.h"
#include "Misc/DefaultValueHelper.h"
#include "Internationalization/Regex.h"
bool FSUDSExpression::ParseFromString(const FString& Expression, FString* OutParseError)
{
// Assume invalid until we've parsed something
bIsValid = false;
Queue.Empty();
VariableNames.Empty();
SourceString = Expression;
// Shunting-yard algorithm
// Thanks to Nathan Reed https://www.reedbeta.com/blog/the-shunting-yard-algorithm/
// We take a natural string, parse it and then turn it into a queue of tokens (operators and operands)
// expressed in Reverse Polish Notation, which can be easily executed later
// Variables are not resolved at this point, only at execution time.
// Split into individual tokens; sections of the regex are:
// - {Variable}
// - Literal numbers (with or without decimal point, with or without preceding negation)
// - Arithmetic operators & parentheses
// - Boolean operators & comparisons
// - Predefined constants (Masculine, feminine, true, false etc)
// - Quoted strings "string"
// - Including ignoring escaped double quotes
// - Quoted names `name`
const FRegexPattern Pattern(TEXT("(\\{[\\w\\.]+\\}|-?\\d+(?:\\.\\d*)?|[-+*\\/%\\(\\)]|and|&&|\\|\\||or|not|\\<\\>|!=|!|\\<=?|\\>=?|==?|[mM]asculine|[fF]eminine|[nN]euter|[tT]rue|[fF]alse|\"(?:[^\"\\\\]|\\\\.)*\"|`([^`]*)`)"));
FRegexMatcher Regex(Pattern, Expression);
// Stacks that we use to construct
TArray<ESUDSExpressionItemType> OperatorStack;
bool bParsedSomething = false;
bool bErrors = false;
while (Regex.FindNext())
{
FString Str = Regex.GetCaptureGroup(1);
ESUDSExpressionItemType OpType = ParseOperator(Str);
if (OpType != ESUDSExpressionItemType::Null)
{
bParsedSomething = true;
if (OpType == ESUDSExpressionItemType::LParens)
{
OperatorStack.Push(OpType);
}
else if (OpType == ESUDSExpressionItemType::RParens)
{
if (OperatorStack.IsEmpty())
{
if (OutParseError)
*OutParseError = TEXT("Mismatched parentheses");
bErrors = true;
break;
}
while (OperatorStack.Num() > 0 && OperatorStack.Top() != ESUDSExpressionItemType::LParens)
{
Queue.Add(FSUDSExpressionItem(OperatorStack.Pop()));
}
if (OperatorStack.IsEmpty())
{
if (OutParseError)
*OutParseError = TEXT("Mismatched parentheses");
bErrors = true;
break;
}
else
{
// Discard left parens
OperatorStack.Pop();
}
}
else
{
// All operators are left-associative except not
const bool bLeftAssociative = OpType != ESUDSExpressionItemType::Not;
// Valid operator
// Apply anything on the operator stack which is higher / equal precedence
while (OperatorStack.Num() > 0 &&
// higher precedence applied now, and equal precedence if left-associative
(static_cast<int>(OperatorStack.Top()) < static_cast<int>(OpType) ||
(static_cast<int>(OperatorStack.Top()) <= static_cast<int>(OpType) && bLeftAssociative)))
{
Queue.Add(FSUDSExpressionItem(OperatorStack.Pop()));
}
OperatorStack.Push(OpType);
}
}
else
{
// Attempt to parse operand
FSUDSValue Operand;
if (ParseOperand(Str, Operand))
{
bParsedSomething = true;
Queue.Add(FSUDSExpressionItem(Operand));
}
else
{
if (OutParseError)
*OutParseError = FString::Printf(TEXT("Unrecognised token %s"), *Str);
bErrors = true;
}
}
}
// finish up
while (OperatorStack.Num() > 0)
{
if (OperatorStack.Top() == ESUDSExpressionItemType::LParens ||
OperatorStack.Top() == ESUDSExpressionItemType::RParens)
{
bErrors = true;
if (OutParseError)
*OutParseError = TEXT("Mismatched parentheses");
break;
}
Queue.Add(FSUDSExpressionItem(OperatorStack.Pop()));
}
if (!Validate() ||
(Expression.Len() > 0 && Queue.IsEmpty())) // Empty expressions validate correctly, but if there was text incoming that resolved to nothing, this is an error
{
bErrors = true;
if (OutParseError)
*OutParseError = FString::Printf(TEXT("Bad expression '%s'"), *Expression);
}
bIsValid = bParsedSomething && !bErrors;
// Build list of variables
if (bIsValid)
{
for (auto& Item : Queue)
{
if (Item.IsOperand() && Item.GetOperandValue().IsVariable())
{
VariableNames.AddUnique(Item.GetOperandValue().GetVariableNameValue());
}
}
}
return bIsValid;
}
void FSUDSExpression::Reset()
{
bIsValid = true;
Queue.Empty();
VariableNames.Empty();
SourceString = "";
}
bool FSUDSExpression::IsRandomCondition() const
{
if (Queue.Num() > 0 && Queue[0].GetType() == ESUDSExpressionItemType::Operand)
{
const auto& Operand = Queue[0].GetOperandValue();
return Operand.IsVariable() && Operand.GetVariableNameValue() == FSUDSConstants::RandomItemSelectIndexVarName;
}
return false;
}
ESUDSExpressionItemType FSUDSExpression::ParseOperator(const FString& OpStr)
{
if (OpStr == "+")
return ESUDSExpressionItemType::Add;
if (OpStr == "-")
return ESUDSExpressionItemType::Subtract;
if (OpStr == "*")
return ESUDSExpressionItemType::Multiply;
if (OpStr == "/")
return ESUDSExpressionItemType::Divide;
if (OpStr == "%")
return ESUDSExpressionItemType::Modulo;
if (OpStr == "and" || OpStr == "&&")
return ESUDSExpressionItemType::And;
if (OpStr == "or" || OpStr == "||")
return ESUDSExpressionItemType::Or;
if (OpStr == "not" || OpStr == "!")
return ESUDSExpressionItemType::Not;
if (OpStr == "==" || OpStr == "=")
return ESUDSExpressionItemType::Equal;
if (OpStr == ">=")
return ESUDSExpressionItemType::GreaterEqual;
if (OpStr == ">")
return ESUDSExpressionItemType::Greater;
if (OpStr == "<=")
return ESUDSExpressionItemType::LessEqual;
if (OpStr == "<")
return ESUDSExpressionItemType::Less;
if (OpStr == "<>" || OpStr == "!=")
return ESUDSExpressionItemType::NotEqual;
if (OpStr == "(")
return ESUDSExpressionItemType::LParens;
if (OpStr == ")")
return ESUDSExpressionItemType::RParens;
return ESUDSExpressionItemType::Null;
}
bool FSUDSExpression::ParseOperand(const FString& ValueStr, FSUDSValue& OutVal)
{
// Try Boolean first since only 2 options
{
if (ValueStr.Compare("true", ESearchCase::IgnoreCase) == 0)
{
OutVal = FSUDSValue(true);
return true;
}
if (ValueStr.Compare("false", ESearchCase::IgnoreCase) == 0)
{
OutVal = FSUDSValue(false);
return true;
}
}
// Try gender
{
if (ValueStr.Compare("masculine", ESearchCase::IgnoreCase) == 0)
{
OutVal = FSUDSValue(ETextGender::Masculine);
return true;
}
if (ValueStr.Compare("feminine", ESearchCase::IgnoreCase) == 0)
{
OutVal = FSUDSValue(ETextGender::Feminine);
return true;
}
if (ValueStr.Compare("neuter", ESearchCase::IgnoreCase) == 0)
{
OutVal = FSUDSValue(ETextGender::Neuter);
return true;
}
}
// Try quoted text (will be localised later in asset conversion)
{
const FRegexPattern Pattern(TEXT("^\"((?:[^\"\\\\]|\\\\.)*)\"$"));
FRegexMatcher Regex(Pattern, ValueStr);
if (Regex.FindNext())
{
FString Val = Regex.GetCaptureGroup(1);
// Consolidate any escaped double quotes into just quotes
Val.ReplaceInline(TEXT("\\\""), TEXT("\""));
OutVal = FSUDSValue(FText::FromString(Val));
return true;
}
}
// Try FName
{
const FRegexPattern Pattern(TEXT("^`([^`]*)`$"));
FRegexMatcher Regex(Pattern, ValueStr);
if (Regex.FindNext())
{
const FString Name = Regex.GetCaptureGroup(1);
OutVal = FSUDSValue(FName(Name), false);
return true;
}
}
// Try variable name
{
const FRegexPattern Pattern(TEXT("^\\{([^\\}]*)\\}$"));
FRegexMatcher Regex(Pattern, ValueStr);
if (Regex.FindNext())
{
const FName VariableName(Regex.GetCaptureGroup(1));
OutVal = FSUDSValue(VariableName, true);
return true;
}
}
// Try Numbers
{
float FloatVal;
int IntVal;
// look for int first; anything with a decimal point will fail
if (FDefaultValueHelper::ParseInt(ValueStr, IntVal))
{
OutVal = FSUDSValue(IntVal);
return true;
}
if (FDefaultValueHelper::ParseFloat(ValueStr, FloatVal))
{
OutVal = FSUDSValue(FloatVal);
return true;
}
}
return false;
}
bool FSUDSExpression::Validate()
{
// Empty expressions are always valid, mean "true"
if (Queue.IsEmpty())
return true;
// Same algorithm as Execute, we just don't execute
TArray<FSUDSExpressionItem> EvalStack;
const TMap<FName, FSUDSValue> TempVariables;
for (auto& Item : Queue)
{
if (Item.IsOperator())
{
FSUDSExpressionItem Arg1, Arg2;
if (Item.IsBinaryOperator())
{
if (EvalStack.IsEmpty())
return false;
Arg2 = EvalStack.Pop();
}
if (EvalStack.IsEmpty())
return false;
Arg1 = EvalStack.Pop();
EvalStack.Push(EvaluateOperator(Item.GetType(), Arg1, Arg2, TempVariables, TempVariables));
}
else
{
EvalStack.Push(Item);
}
}
// Must be one item left and must be an operand
return EvalStack.Num() == 1 && EvalStack[0].IsOperand();
}
FSUDSValue FSUDSExpression::Evaluate(const TMap<FName, FSUDSValue>& Variables, const TMap<FName, FSUDSValue>& GlobalVariables) const
{
checkf(bIsValid, TEXT("Cannot execute an invalid expression tree"));
// Blanks are mostly used for conditionals, for simplicity always return true
if (Queue.IsEmpty())
return FSUDSValue(true);
TArray<FSUDSExpressionItem> EvalStack;
// We could pre-optimise all literal expressions, but let's not for now
for (auto& Item : Queue)
{
if (Item.IsOperator())
{
FSUDSExpressionItem Arg1, Arg2;
// Arg2 (RHS) has to be popped first
if (Item.IsBinaryOperator())
{
checkf(!EvalStack.IsEmpty(), TEXT("Args missing before operator, bad expression"));
Arg2 = EvalStack.Pop();
}
checkf(!EvalStack.IsEmpty(), TEXT("Args missing before operator, bad expression"));
Arg1 = EvalStack.Pop();
EvalStack.Push(EvaluateOperator(Item.GetType(), Arg1, Arg2, Variables, GlobalVariables));
}
else
{
EvalStack.Push(Item);
}
}
checkf(EvalStack.Num() == 1, TEXT("We should end with a single item in the eval stack and it should be an operand"));
return EvaluateOperand(EvalStack.Top().GetOperandValue(), Variables, GlobalVariables);
}
bool FSUDSExpression::EvaluateBoolean(const TMap<FName, FSUDSValue>& Variables, const TMap<FName, FSUDSValue>& GlobalVariables, const FString& ErrorContext) const
{
const auto Result = Evaluate(Variables, GlobalVariables);
if (Result.GetType() != ESUDSValueType::Boolean &&
Result.GetType() != ESUDSValueType::Variable) // Allow unresolved variable, will assume false
{
UE_LOG(LogSUDS, Error, TEXT("%s: Condition '%s' did not return a boolean result"), *ErrorContext, *SourceString)
}
return Result.GetBooleanValue();
}
FSUDSExpressionItem FSUDSExpression::EvaluateOperator(ESUDSExpressionItemType Op,
const FSUDSExpressionItem& Arg1,
const FSUDSExpressionItem& Arg2,
const TMap<FName, FSUDSValue>& Variables,
const TMap<FName, FSUDSValue>& GlobalVariables) const
{
const FSUDSValue Val1 = EvaluateOperand(Arg1.GetOperandValue(), Variables, GlobalVariables);
FSUDSValue Val2;
if (Arg1.IsBinaryOperator())
{
Val2 = EvaluateOperand(Arg2.GetOperandValue(), Variables, GlobalVariables);
}
switch (Op)
{
case ESUDSExpressionItemType::Not:
return FSUDSExpressionItem(!Val1);
case ESUDSExpressionItemType::Multiply:
return FSUDSExpressionItem(Val1 * Val2);
case ESUDSExpressionItemType::Divide:
return FSUDSExpressionItem(Val1 / Val2);
case ESUDSExpressionItemType::Modulo:
return FSUDSExpressionItem(Val1 % Val2);
case ESUDSExpressionItemType::Add:
return FSUDSExpressionItem(Val1 + Val2);
case ESUDSExpressionItemType::Subtract:
return FSUDSExpressionItem(Val1 - Val2);
case ESUDSExpressionItemType::Less:
return FSUDSExpressionItem(Val1 < Val2);
case ESUDSExpressionItemType::LessEqual:
return FSUDSExpressionItem(Val1 <= Val2);
case ESUDSExpressionItemType::Greater:
return FSUDSExpressionItem(Val1 > Val2);
case ESUDSExpressionItemType::GreaterEqual:
return FSUDSExpressionItem(Val1 >= Val2);
case ESUDSExpressionItemType::Equal:
return FSUDSExpressionItem(Val1 == Val2);
case ESUDSExpressionItemType::NotEqual:
return FSUDSExpressionItem(Val1 != Val2);
case ESUDSExpressionItemType::And:
return FSUDSExpressionItem(Val1 && Val2);
case ESUDSExpressionItemType::Or:
return FSUDSExpressionItem(Val1 || Val2);
default: // these won't occur
case ESUDSExpressionItemType::Null:
case ESUDSExpressionItemType::Operand:
case ESUDSExpressionItemType::LParens:
case ESUDSExpressionItemType::RParens:
return FSUDSExpressionItem();
};
}
FSUDSValue FSUDSExpression::EvaluateOperand(const FSUDSValue& Operand,
const TMap<FName, FSUDSValue>& Variables,
const TMap<FName, FSUDSValue>& GlobalVariables) const
{
// Simplify conversion to variable values
if (Operand.IsVariable())
{
FName Name = Operand.GetVariableNameValue();
FName GlobalName;
if (USUDSLibrary::IsDialogueVariableGlobal(Name, GlobalName))
{
// This will have stripped the prefix so direct find is OK
if (const auto Var = GlobalVariables.Find(GlobalName))
{
return *Var;
}
}
if (const auto Var = Variables.Find(Operand.GetVariableNameValue()))
{
return *Var;
}
// Note: we're NOT warning about unset variables here, and just defaulting to initial values (false, 0 etc)
// This is more usable in practice than complaining about it
}
return Operand;
}

View File

@@ -0,0 +1,41 @@
// Copyright Steve Streeting 2022
// Released under the MIT license https://opensource.org/license/MIT/
#pragma once
#include "SUDSSubsystem.h"
#include "SUDSValue.h"
inline const TMap<FName, FSUDSValue>& InternalGetGlobalVariables(UWorld* WorldContext)
{
if (auto Sub = GetSUDSSubsystem(WorldContext))
{
return Sub->GetGlobalVariables();
}
#if WITH_EDITORONLY_DATA
// In editor mode, return static global vars since we may be unit testing or in editor tester
return USUDSSubsystem::Test_DummyGlobalVariables;
#else
static TMap<FName, FSUDSValue> Blank;
return Blank;
#endif
}
// For our code only
inline void InternalSetGlobalVariable(UWorld* WorldContext, FName Name, const FSUDSValue& Value, bool bFromScript, const FString& ScriptName, int LineNo)
{
if (auto Sub = GetSUDSSubsystem(WorldContext))
{
Sub->InternalSetGlobalVariable(Name, Value, bFromScript, LineNo);
}
else
{
#if WITH_EDITORONLY_DATA
// In editor mode, update static global vars since we may be unit testing or in editor tester
USUDSSubsystem::Test_DummyGlobalVariables.Add(Name, Value);
#else
checkf(false, TEXT("%s: Attempted to set global variable %s with no world context, did you forget to set the dialogue owner?"), *ScriptName, *Name.ToString());
#endif
}
}

View File

@@ -0,0 +1,154 @@
// Copyright Steve Streeting 2022
// Released under the MIT license https://opensource.org/license/MIT/
#include "SUDSLibrary.h"
#include "SUDSDialogue.h"
#include "SUDSScript.h"
#include "UObject/Package.h"
USUDSDialogue* USUDSLibrary::CreateDialogue(UObject* Owner, USUDSScript* Script, bool bStartImmediately, FName StartLabel)
{
if (IsValid(Script))
{
if (!IsValid(Owner))
{
Owner = GetTransientPackage();
}
const FName Name = MakeUniqueObjectName(Owner, USUDSDialogue::StaticClass(), Script->GetFName());
USUDSDialogue* Ret = NewObject<USUDSDialogue>(Owner, Name);
Ret->Initialise(Script);
if (bStartImmediately)
{
Ret->Start(StartLabel);
}
return Ret;
}
UE_LOG(LogSUDS, Error, TEXT("Called CreateDialogue with an invalid script"))
return nullptr;
}
USUDSDialogue* USUDSLibrary::CreateDialogueWithParticipants(UObject* Owner,
USUDSScript* Script,
const TArray<UObject*>& Participants, bool bStartImmediately, FName StartLabel)
{
if (IsValid(Script))
{
if (!IsValid(Owner))
{
Owner = GetTransientPackage();
}
// Don't use the base CreateDialogue to start, we want to set participants first before init/start
USUDSDialogue* Dlg = NewObject<USUDSDialogue>(Owner, Script->GetFName());
Dlg->SetParticipants(Participants);
Dlg->Initialise(Script);
if (bStartImmediately)
{
Dlg->Start(StartLabel);
}
return Dlg;
}
UE_LOG(LogSUDS, Error, TEXT("Called CreateDialogue with an invalid script"))
return nullptr;
}
USUDSDialogue* USUDSLibrary::CreateDialogueWithParticipant(UObject* Owner,
USUDSScript* Script,
UObject* Participant,
bool bStartImmediately,
FName StartLabel)
{
TArray<UObject*> Participants;
Participants.Add(Participant);
return CreateDialogueWithParticipants(Owner, Script, Participants, bStartImmediately, StartLabel);
}
bool USUDSLibrary::GetDialogueValueAsText(const FSUDSValue& Value, FText& TextValue)
{
if (Value.GetType() == ESUDSValueType::Text)
{
TextValue = Value.GetTextValue();
return true;
}
return false;
}
bool USUDSLibrary::GetDialogueValueAsBoolean(const FSUDSValue& Value, bool& BoolValue)
{
if (Value.GetType() == ESUDSValueType::Boolean)
{
BoolValue = Value.GetBooleanValue();
return true;
}
return false;
}
bool USUDSLibrary::GetDialogueValueAsInt(const FSUDSValue& Value, int& IntValue)
{
if (Value.GetType() == ESUDSValueType::Int)
{
IntValue = Value.GetIntValue();
return true;
}
return false;
}
bool USUDSLibrary::GetDialogueValueAsFloat(const FSUDSValue& Value, float& FloatValue)
{
if (Value.GetType() == ESUDSValueType::Float)
{
FloatValue = Value.GetFloatValue();
return true;
}
return false;
}
bool USUDSLibrary::GetDialogueValueAsGender(const FSUDSValue& Value, ETextGender& GenderValue)
{
if (Value.GetType() == ESUDSValueType::Gender)
{
GenderValue = Value.GetGenderValue();
return true;
}
return false;
}
bool USUDSLibrary::GetDialogueValueAsName(const FSUDSValue& Value, FName& NameValue)
{
if (Value.GetType() == ESUDSValueType::Name)
{
NameValue = Value.GetNameValue();
return true;
}
return false;
}
ESUDSValueType USUDSLibrary::GetDialogueValueType(const FSUDSValue& Value)
{
return Value.GetType();
}
bool USUDSLibrary::GetDialogueValueIsEmpty(const FSUDSValue& Value)
{
return Value.IsEmpty();
}
bool USUDSLibrary::IsDialogueVariableGlobal(const FName& Name, FName& OutName)
{
static const FString Prefix(TEXT("global."));
FString TempStr;
Name.ToString(TempStr);
if (TempStr.StartsWith(Prefix, ESearchCase::IgnoreCase))
{
TempStr.RightChopInline(Prefix.Len());
OutName = FName(TempStr);
return true;
}
else
{
OutName = Name;
return false;
}
}

View File

@@ -0,0 +1,5 @@
// Copyright Steve Streeting 2022
// Released under the MIT license https://opensource.org/license/MIT/
#include "SUDSParticipant.h"

View File

@@ -0,0 +1,276 @@
// Copyright Steve Streeting 2022
// Released under the MIT license https://opensource.org/license/MIT/
#include "SUDSScript.h"
#include "SUDSScriptNode.h"
#include "SUDSScriptNodeGosub.h"
#include "SUDSScriptNodeText.h"
#include "EditorFramework/AssetImportData.h"
void USUDSScript::StartImport(TArray<TObjectPtr<USUDSScriptNode>>** ppNodes,
TArray<TObjectPtr<USUDSScriptNode>>** ppHeaderNodes,
TMap<FName, int>** ppLabelList,
TMap<FName, int>** ppHeaderLabelList,
TArray<FString>** ppSpeakerList)
{
*ppNodes = &Nodes;
*ppHeaderNodes = &HeaderNodes;
*ppLabelList = &LabelList;
*ppHeaderLabelList = &HeaderLabelList;
*ppSpeakerList = &Speakers;
}
USUDSScriptNode* USUDSScript::GetNextNode(const USUDSScriptNode* Node) const
{
switch (Node->GetEdgeCount())
{
case 0:
return nullptr;
case 1:
return Node->GetEdge(0)->GetTargetNode().Get();
default:
UE_LOG(LogSUDS, Error, TEXT("Called GetNextNode on a node with more than one edge"));
return nullptr;
}
}
#define kChoiceFound 1
#define kChoiceNotFoundBeforeText -1
#define kChoiceNotFoundBeforeEnd 0
bool USUDSScript::DoesAnyPathAfterLeadToChoice(USUDSScriptNode* FromNode)
{
// Look for any possible choice following a node (text or gosub)
// If it's possible to find a choice in one of the paths ahead, before another text node, the return true
// Given that there might be conditionals, not all paths might lead to a choice, but we only care if one of them does
// We recurse into conditional paths where they exist until we know.
// For a gosub this is looking for the next after a return, not inside the sub
USUDSScriptNode* CurrNode = GetNextNode(FromNode);
return RecurseLookForChoice(CurrNode) == kChoiceFound;
}
int USUDSScript::RecurseLookForChoice(USUDSScriptNode* CurrNode)
{
// Return int so that we can differentiate:
// 1 = we found a choice
// 0 = we didn't find a choice, but also didn't hit another text node (reached end, or gosub return)
// -1 = we hit a text node
while (CurrNode)
{
switch (CurrNode->GetNodeType())
{
case ESUDSScriptNodeType::Text:
// if we hit a text node, there was no choice
return kChoiceNotFoundBeforeText;
case ESUDSScriptNodeType::Choice:
// we found a choice
return kChoiceFound;
case ESUDSScriptNodeType::Select:
{
// Explore all possible routes
int WorstResult = kChoiceNotFoundBeforeEnd;
for (auto& Edge : CurrNode->GetEdges())
{
auto TargetNode = Edge.GetTargetNode();
if (TargetNode.IsValid())
{
const int ConditionalPath = RecurseLookForChoice(TargetNode.Get());
if (ConditionalPath == kChoiceFound)
return kChoiceFound;
WorstResult = FMath::Min(ConditionalPath, WorstResult);
}
}
return WorstResult;
}
case ESUDSScriptNodeType::Event:
case ESUDSScriptNodeType::SetVariable:
CurrNode = GetNextNode(CurrNode);
break;
case ESUDSScriptNodeType::Gosub:
// When we hit a gosub here we go into it, not after it
if (auto GosubNode = Cast<USUDSScriptNodeGosub>(CurrNode))
{
int SubResult = RecurseLookForChoice(GetNodeByLabel(GosubNode->GetLabelName()));
if (SubResult != 0)
{
// Found definitive result (choice or text) inside sub
return SubResult;
}
}
// Otherwise, we didn't conclude within the sub, continue following it
CurrNode = GetNextNode(CurrNode);
break;
default: ;
case ESUDSScriptNodeType::Return:
// this is when we're exploring a sub for the choice
return kChoiceNotFoundBeforeEnd;
};
}
return kChoiceNotFoundBeforeEnd;
}
void USUDSScript::FinishImport()
{
// As an optimisation, make all text/gosub nodes pre-scan their follow-on nodes for choice nodes
// We can actually have intermediate nodes, for example set nodes which run for all choices that are placed
// between the text and the first choice. Resolve whether they exist now
for (auto Node : Nodes)
{
if (Node->GetNodeType() == ESUDSScriptNodeType::Text ||
Node->GetNodeType() == ESUDSScriptNodeType::Gosub)
{
if (DoesAnyPathAfterLeadToChoice(Node))
{
switch (Node->GetNodeType())
{
case ESUDSScriptNodeType::Text:
{
if (auto TextNode = Cast<USUDSScriptNodeText>(Node))
{
TextNode->NotifyMayHaveChoices();
}
break;
}
case ESUDSScriptNodeType::Gosub:
{
if (auto GosubNode = Cast<USUDSScriptNodeGosub>(Node))
{
GosubNode->NotifyMayHaveChoices();
}
break;
}
default: break;
}
}
}
}
}
USUDSScriptNode* USUDSScript::GetHeaderNode() const
{
if (HeaderNodes.Num() > 0)
return HeaderNodes[0];
return nullptr;
}
USUDSScriptNode* USUDSScript::GetFirstNode() const
{
if (Nodes.Num() > 0)
return Nodes[0];
return nullptr;
}
USUDSScriptNode* USUDSScript::GetNodeByLabel(const FName& Label) const
{
if (const int* pIdx = LabelList.Find(Label))
{
return Nodes[*pIdx];
}
return nullptr;
}
USUDSScriptNodeText* USUDSScript::GetNodeByTextID(const FString& TextID) const
{
for (auto N : Nodes)
{
if (N->GetNodeType() == ESUDSScriptNodeType::Text)
{
if (auto TN = Cast<USUDSScriptNodeText>(N))
{
if (TextID.Equals(TN->GetTextID()))
{
return TN;
}
}
}
}
return nullptr;
}
USUDSScriptNodeGosub* USUDSScript::GetNodeByGosubID(const FString& ID) const
{
for (auto N : Nodes)
{
if (N->GetNodeType() == ESUDSScriptNodeType::Text)
{
if (auto GN = Cast<USUDSScriptNodeGosub>(N))
{
if (ID.Equals(GN->GetGosubID()))
{
return GN;
}
}
}
}
return nullptr;
}
UDialogueVoice* USUDSScript::GetSpeakerVoice(const FString& SpeakerID) const
{
if (auto pVoice = SpeakerVoices.Find(SpeakerID))
{
return *pVoice;
}
return nullptr;
}
void USUDSScript::SetSpeakerVoice(const FString& SpeakerID, UDialogueVoice* Voice)
{
SpeakerVoices.Add(SpeakerID, Voice);
}
#if WITH_EDITORONLY_DATA
void USUDSScript::PostInitProperties()
{
if (!HasAnyFlags(RF_ClassDefaultObject))
{
AssetImportData = NewObject<UAssetImportData>(this, TEXT("AssetImportData"));
}
Super::PostInitProperties();
}
#if ENGINE_MAJOR_VERSION == 5 && ENGINE_MINOR_VERSION >= 4
void USUDSScript::GetAssetRegistryTags(FAssetRegistryTagsContext Context) const
{
if (AssetImportData)
{
Context.AddTag( FAssetRegistryTag(SourceFileTagName(), AssetImportData->GetSourceData().ToJson(), FAssetRegistryTag::TT_Hidden) );
}
Super::GetAssetRegistryTags(Context);
}
#else
void USUDSScript::GetAssetRegistryTags(TArray<FAssetRegistryTag>& OutTags) const
{
if (AssetImportData)
{
OutTags.Add( FAssetRegistryTag(SourceFileTagName(), AssetImportData->GetSourceData().ToJson(), FAssetRegistryTag::TT_Hidden) );
}
Super::GetAssetRegistryTags(OutTags);
}
#endif
void USUDSScript::Serialize(FArchive& Ar)
{
Super::Serialize(Ar);
if (Ar.IsLoading() && Ar.UEVer() < VER_UE4_ASSET_IMPORT_DATA_AS_JSON && !AssetImportData)
{
// AssetImportData should always be valid
AssetImportData = NewObject<UAssetImportData>(this, TEXT("AssetImportData"));
}
}
#endif

View File

@@ -0,0 +1,79 @@
// Copyright Steve Streeting 2022
// Released under the MIT license https://opensource.org/license/MIT/
#include "SUDSScriptEdge.h"
#include "SUDSScriptNode.h"
FSUDSScriptEdge::FSUDSScriptEdge(USUDSScriptNode* ToNode, ESUDSEdgeType InType, int LineNo): Type(InType),
TargetNode(ToNode),
SourceLineNo(LineNo)
{
}
FSUDSScriptEdge::FSUDSScriptEdge(const FText& InText, USUDSScriptNode* ToNode, int LineNo): Text(InText),
Type(ESUDSEdgeType::Decision),
TargetNode(ToNode),
SourceLineNo(LineNo)
{
}
void FSUDSScriptEdge::ExtractFormat() const
{
// Only do this on demand, and only once
TextFormat = Text;
ParameterNames.Empty();
TArray<FString> TextParams;
TextFormat.GetFormatArgumentNames(TextParams);
for (auto Param : TextParams)
{
ParameterNames.Add(FName(Param));
}
bFormatExtracted = true;
}
FString FSUDSScriptEdge::GetTextID() const
{
return SUDS_GET_TEXT_KEY(Text);
}
void FSUDSScriptEdge::SetText(const FText& InText)
{
Text = InText;
bFormatExtracted = false;
}
void FSUDSScriptEdge::SetTargetNode(const TWeakObjectPtr<USUDSScriptNode>& InTargetNode)
{
TargetNode = InTargetNode;
}
const FTextFormat& FSUDSScriptEdge::GetTextFormat() const
{
if (!bFormatExtracted)
{
ExtractFormat();
}
return TextFormat;
}
const TArray<FName>& FSUDSScriptEdge::GetParameterNames() const
{
if (!bFormatExtracted)
{
ExtractFormat();
}
return ParameterNames;
}
bool FSUDSScriptEdge::HasParameters() const
{
if (!bFormatExtracted)
{
ExtractFormat();
}
return !ParameterNames.IsEmpty();
}

View File

@@ -0,0 +1,38 @@
// Copyright Steve Streeting 2022
// Released under the MIT license https://opensource.org/license/MIT/
#include "SUDSScriptNode.h"
USUDSScriptNode::USUDSScriptNode()
{
}
void USUDSScriptNode::InitChoice(int LineNo)
{
NodeType = ESUDSScriptNodeType::Choice;
SourceLineNo = LineNo;
}
void USUDSScriptNode::InitSelect(int LineNo)
{
NodeType = ESUDSScriptNodeType::Select;
SourceLineNo = LineNo;
}
void USUDSScriptNode::InitReturn(int LineNo)
{
NodeType = ESUDSScriptNodeType::Return;
SourceLineNo = LineNo;
}
bool USUDSScriptNode::IsRandomSelect() const
{
return NodeType == ESUDSScriptNodeType::Select &&
GetEdgeCount() > 0 &&
GetEdge(0)->GetCondition().IsRandomCondition();
}
void USUDSScriptNode::AddEdge(const FSUDSScriptEdge& NewEdge)
{
Edges.Add(NewEdge);
}

View File

@@ -0,0 +1,12 @@
// Copyright Steve Streeting 2022
// Released under the MIT license https://opensource.org/license/MIT/
#include "SUDSScriptNodeEvent.h"
void USUDSScriptNodeEvent::Init(const FString& EvtName, const TArray<FSUDSExpression>& InArgs, int LineNo)
{
NodeType = ESUDSScriptNodeType::Event;
EventName = FName(EvtName);
Args = InArgs;
SourceLineNo = LineNo;
}

View File

@@ -0,0 +1,11 @@
// Copyright Steve Streeting 2022
// Released under the MIT license https://opensource.org/license/MIT/
#include "SUDSScriptNodeSet.h"
void USUDSScriptNodeSet::Init(const FString& VarName, const FSUDSExpression& InExpression, int LineNo)
{
NodeType = ESUDSScriptNodeType::SetVariable;
Identifier = FName(VarName);
Expression = InExpression;
SourceLineNo = LineNo;
}

View File

@@ -0,0 +1,62 @@
// Copyright Steve Streeting 2022
// Released under the MIT license https://opensource.org/license/MIT/
#include "SUDSScriptNodeText.h"
void USUDSScriptNodeText::Init(const FString& InSpeakerID, const FText& InText, int LineNo)
{
NodeType = ESUDSScriptNodeType::Text;
SpeakerID = InSpeakerID;
Text = InText;
TextFormat = Text;
SourceLineNo = LineNo;
bFormatExtracted = false;
}
FString USUDSScriptNodeText::GetTextID() const
{
return SUDS_GET_TEXT_KEY(Text);
}
const FTextFormat& USUDSScriptNodeText::GetTextFormat() const
{
if (!bFormatExtracted)
{
ExtractFormat();
}
return TextFormat;
}
const TArray<FName>& USUDSScriptNodeText::GetParameterNames() const
{
if (!bFormatExtracted)
{
ExtractFormat();
}
return ParameterNames;
}
bool USUDSScriptNodeText::HasParameters() const
{
if (!bFormatExtracted)
{
ExtractFormat();
}
return !ParameterNames.IsEmpty();
}
void USUDSScriptNodeText::ExtractFormat() const
{
// Only do this on demand, and only once
TextFormat = Text;
ParameterNames.Empty();
TArray<FString> TextParams;
TextFormat.GetFormatArgumentNames(TextParams);
for (auto Param : TextParams)
{
ParameterNames.Add(FName(Param));
}
bFormatExtracted = true;
}

View File

@@ -0,0 +1,199 @@
// Copyright Steve Streeting 2022
// Released under the MIT license https://opensource.org/license/MIT/
#include "SUDSSubsystem.h"
#include "Sound/SoundConcurrency.h"
DEFINE_LOG_CATEGORY(LogSUDSSubsystem)
#if WITH_EDITORONLY_DATA
TMap<FName, FSUDSValue> USUDSSubsystem::Test_DummyGlobalVariables;
#endif
void USUDSSubsystem::Initialize(FSubsystemCollectionBase& Collection)
{
Super::Initialize(Collection);
// Default to a single voice line being played at once
VoiceConcurrency = NewObject<USoundConcurrency>(this);
VoiceConcurrency->Concurrency.MaxCount = 1;
}
void USUDSSubsystem::Deinitialize()
{
Super::Deinitialize();
}
void USUDSSubsystem::SetMaxConcurrentVoicedLines(int ConcurrentLines)
{
if (IsValid(VoiceConcurrency))
{
VoiceConcurrency->Concurrency.MaxCount = ConcurrentLines;
}
}
int USUDSSubsystem::GetMaxConcurrentVoicedLines() const
{
if (IsValid(VoiceConcurrency))
{
return VoiceConcurrency->Concurrency.MaxCount;
}
return 1;
}
void USUDSSubsystem::ResetGlobalState(bool bResetVariables)
{
if (bResetVariables)
GlobalVariableState.Empty();
}
FSUDSGlobalState USUDSSubsystem::GetSavedGlobalState() const
{
return FSUDSGlobalState(GlobalVariableState);
}
void USUDSSubsystem::RestoreSavedGlobalState(const FSUDSGlobalState& State)
{
ResetGlobalState();
GlobalVariableState.Append(State.GetGlobalVariables());
}
FText USUDSSubsystem::GetGlobalVariableText(FName Name) const
{
if (const auto Arg = GlobalVariableState.Find(Name))
{
if (Arg->GetType() == ESUDSValueType::Text)
{
return Arg->GetTextValue();
}
else
{
UE_LOG(LogSUDSSubsystem, Error, TEXT("Requested variable %s of type text but was not a compatible type"), *Name.ToString());
}
}
return FText();
}
void USUDSSubsystem::SetGlobalVariableInt(FName Name, int32 Value)
{
SetGlobalVariable(Name, Value);
}
int USUDSSubsystem::GetGlobalVariableInt(FName Name) const
{
if (const auto Arg = GlobalVariableState.Find(Name))
{
switch (Arg->GetType())
{
case ESUDSValueType::Int:
return Arg->GetIntValue();
case ESUDSValueType::Float:
UE_LOG(LogSUDSSubsystem, Warning, TEXT("Casting variable %s to int, data loss may occur"), *Name.ToString());
return Arg->GetFloatValue();
default:
case ESUDSValueType::Gender:
case ESUDSValueType::Text:
UE_LOG(LogSUDSSubsystem, Error, TEXT("Variable %s is not a compatible integer type"), *Name.ToString());
}
}
return 0;
}
void USUDSSubsystem::SetGlobalVariableFloat(FName Name, float Value)
{
SetGlobalVariable(Name, Value);
}
float USUDSSubsystem::GetGlobalVariableFloat(FName Name) const
{
if (const auto Arg = GlobalVariableState.Find(Name))
{
switch (Arg->GetType())
{
case ESUDSValueType::Int:
return Arg->GetIntValue();
case ESUDSValueType::Float:
return Arg->GetFloatValue();
default:
case ESUDSValueType::Gender:
case ESUDSValueType::Text:
UE_LOG(LogSUDSSubsystem, Error, TEXT("Variable %s is not a compatible float type"), *Name.ToString());
}
}
return 0;
}
void USUDSSubsystem::SetGlobalVariableGender(FName Name, ETextGender Value)
{
SetGlobalVariable(Name, Value);
}
ETextGender USUDSSubsystem::GetGlobalVariableGender(FName Name) const
{
if (const auto Arg = GlobalVariableState.Find(Name))
{
switch (Arg->GetType())
{
case ESUDSValueType::Gender:
return Arg->GetGenderValue();
default:
case ESUDSValueType::Int:
case ESUDSValueType::Float:
case ESUDSValueType::Text:
UE_LOG(LogSUDSSubsystem, Error, TEXT("Variable %s is not a compatible gender type"), *Name.ToString());
}
}
return ETextGender::Neuter;
}
void USUDSSubsystem::SetGlobalVariableBoolean(FName Name, bool Value)
{
// Use explicit FSUDSValue constructor to avoid default int conversion
SetGlobalVariable(Name, FSUDSValue(Value));
}
bool USUDSSubsystem::GetGlobalVariableBoolean(FName Name) const
{
if (const auto Arg = GlobalVariableState.Find(Name))
{
switch (Arg->GetType())
{
case ESUDSValueType::Boolean:
return Arg->GetBooleanValue();
case ESUDSValueType::Int:
return Arg->GetIntValue() != 0;
default:
case ESUDSValueType::Float:
case ESUDSValueType::Gender:
case ESUDSValueType::Text:
UE_LOG(LogSUDSSubsystem, Error, TEXT("Variable %s is not a compatible boolean type"), *Name.ToString());
}
}
return false;
}
void USUDSSubsystem::SetGlobalVariableName(FName Name, FName Value)
{
SetGlobalVariable(Name, FSUDSValue(Value, false));
}
FName USUDSSubsystem::GetGlobalVariableName(FName Name) const
{
if (const auto Arg = GlobalVariableState.Find(Name))
{
if (Arg->GetType() == ESUDSValueType::Name)
{
return Arg->GetNameValue();
}
else
{
UE_LOG(LogSUDSSubsystem, Error, TEXT("Requested variable %s of type text but was not a compatible type"), *Name.ToString());
}
}
return NAME_None;
}
void USUDSSubsystem::UnSetGlobalVariable(FName Name)
{
GlobalVariableState.Remove(Name);
}

View File

@@ -0,0 +1,93 @@
// Copyright Steve Streeting 2022
// Released under the MIT license https://opensource.org/license/MIT/
#include "SUDSValue.h"
FArchive& operator<<(FArchive& Ar, FSUDSValue& Value)
{
// Custom serialisation since we can't auto-serialise union, TOptional
uint8 TypeAsInt = (uint8)Value.Type;
Ar << TypeAsInt;
if (Ar.IsLoading())
Value.Type = static_cast<ESUDSValueType>(TypeAsInt);
// This gets/sets float value too
Ar << Value.IntValue;
if (Value.Type == ESUDSValueType::Text)
{
FText Text = Value.TextValue.Get(FText::GetEmpty());
Ar << Text;
if (Ar.IsLoading())
Value.TextValue = Text;
}
else if (Value.Type == ESUDSValueType::Variable || Value.Type == ESUDSValueType::Name)
{
FString VarNameStr = Value.Name.Get(NAME_None).ToString();
Ar << VarNameStr;
if (Ar.IsLoading())
Value.Name = FName(VarNameStr);
}
return Ar;
}
void operator<<(FStructuredArchive::FSlot Slot, FSUDSValue& Value)
{
FStructuredArchive::FRecord Record = Slot.EnterRecord();
Record
<< SA_VALUE(TEXT("Type"), Value.Type)
<< SA_VALUE(TEXT("IntValue"), Value.IntValue); // gets/sets float/boolean/gender too
if (Value.Type == ESUDSValueType::Text)
{
Record << SA_VALUE(TEXT("TextValue"), Value.TextValue);
}
else if (Value.Type == ESUDSValueType::Variable || Value.Type == ESUDSValueType::Name)
{
Record << SA_VALUE(TEXT("Name"), Value.Name);
}
}
FString FSUDSValue::ToString() const
{
switch (Type)
{
case ESUDSValueType::Text:
return GetTextValue().ToString();
case ESUDSValueType::Int:
return FString::FromInt(GetIntValue());
case ESUDSValueType::Float:
return FString::SanitizeFloat(GetFloatValue());
case ESUDSValueType::Boolean:
return GetBooleanValue() ? "True" : "False";
case ESUDSValueType::Gender:
return StaticEnum<ETextGender>()->GetNameStringByValue((int64)GetGenderValue());
case ESUDSValueType::Name:
return GetNameValue().ToString();
case ESUDSValueType::Variable:
return GetVariableNameValue().ToString();
default:
case ESUDSValueType::Empty:
return "Empty";
}
}
bool FSUDSValue::ExportTextItem(FString& ValueStr,
FSUDSValue const& DefaultValue,
UObject* Parent,
int32 PortFlags,
UObject* ExportRootScope) const
{
// This is used to generate the blueprint debugger, but also used in serialisation
// We need to only implement it for debugging to avoid breaking anything else
if (0 != (PortFlags & EPropertyPortFlags::PPF_BlueprintDebugView))
{
ValueStr.Appendf(TEXT("Type=%s Value=%s"), *StaticEnum<ESUDSValueType>()->GetDisplayValueAsText(Type).ToString(), *ToString());
return true;
}
// Use the default for everything else
return false;
}

View File

@@ -0,0 +1,15 @@
// Copyright Steve Streeting 2022
// Released under the MIT license https://opensource.org/license/MIT/
#pragma once
#include "CoreMinimal.h"
#include "Modules/ModuleInterface.h"
class FSUDSModule : public IModuleInterface
{
public:
/** IModuleInterface implementation */
virtual void StartupModule() override;
virtual void ShutdownModule() override;
};

View File

@@ -0,0 +1,23 @@
// Copyright Steve Streeting 2022
// Released under the MIT license https://opensource.org/license/MIT/
#pragma once
#include "CoreMinimal.h"
#include "Runtime/Launch/Resources/Version.h"
// Use DECLARE_LOG_CATEGORY_CLASS not DECLARE_LOG_CATEGORY_EXTERN because we use UE_LOG in headers
DECLARE_LOG_CATEGORY_CLASS(LogSUDS, Warning, All)
#define SUDS_RANDOMITEM_VAR "SUDS.RandomItem"
struct FSUDSConstants
{
/// Reserved variable named use to create random results from select nodes
static const FName RandomItemSelectIndexVarName;
};
#if ENGINE_MINOR_VERSION >= 5
#define SUDS_GET_TEXT_KEY(Text) FTextInspector::GetTextId(Text).GetKey().ToString()
#else
#define SUDS_GET_TEXT_KEY(Text) FTextInspector::GetTextId(Text).GetKey().GetChars()
#endif

View File

@@ -0,0 +1,694 @@
// Copyright Steve Streeting 2022
// Released under the MIT license https://opensource.org/license/MIT/
#pragma once
#include "CoreMinimal.h"
#include "SUDSScriptNode.h"
#include "SUDSExpression.h"
#include "UObject/Object.h"
#include "SUDSDialogue.generated.h"
class USUDSScriptNodeGosub;
class USUDSScriptNodeText;
struct FSUDSScriptEdge;
class USUDSScriptNode;
class USUDSScript;
class UDialogueWave;
class UDialogueVoice;
class USoundBase;
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnDialogueSpeakerLine, class USUDSDialogue*, Dialogue);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnDialogueChoice, class USUDSDialogue*, Dialogue, int, ChoiceIndex);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnDialogueProceeding, class USUDSDialogue*, Dialogue);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnDialogueStarting, class USUDSDialogue*, Dialogue, FName, AtLabel);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnDialogueFinished, class USUDSDialogue*, Dialogue);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_ThreeParams(FOnDialogueEvent, class USUDSDialogue*, Dialogue, FName, EventName, const TArray<FSUDSValue>&, Arguments);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_FourParams(FOnVariableChangedEvent, class USUDSDialogue*, Dialogue, FName, VariableName, const FSUDSValue&, Value, bool, bFromScript);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnVariableRequestedEvent, class USUDSDialogue*, Dialogue, FName, VariableName);
#if WITH_EDITOR
// Non-dynamic events for editor use
DECLARE_DELEGATE_TwoParams(FOnDialogueSpeakerLineInternal, class USUDSDialogue* /* Dialogue */, int /*SourceLineNo*/);
DECLARE_DELEGATE_ThreeParams(FOnDialogueChoiceInternal, class USUDSDialogue* /* Dialogue*/, int /*ChoiceIndex*/, int /*SourceLineNo*/);
DECLARE_DELEGATE_OneParam(FOnDialogueProceedingInternal, class USUDSDialogue* /*Dialogue*/);
DECLARE_DELEGATE_TwoParams(FOnDialogueStartingInternal, class USUDSDialogue* /*Dialogue*/, FName /*AtLabel*/);
DECLARE_DELEGATE_OneParam(FOnDialogueFinishedInternal, class USUDSDialogue* /*Dialogue*/);
DECLARE_DELEGATE_FourParams(FOnDialogueEventInternal, class USUDSDialogue* /*Dialogue*/, FName /*EventName*/, const TArray<FSUDSValue>& /*Arguments*/, int /*SourceLineNo*/);
DECLARE_DELEGATE_FiveParams(FOnDialogueVarChangedByScriptInternal, class USUDSDialogue* /* Dialogue*/, FName /*VariableName*/, const FSUDSValue& /*Value*/, const FString& /*ExprString*/, int /*SourceLineNo*/);
DECLARE_DELEGATE_ThreeParams(FOnDialogueVarChangedByCodeInternal, class USUDSDialogue* /* Dialogue*/, FName /*VariableName*/, const FSUDSValue& /*Value*/);
DECLARE_DELEGATE_FourParams(FOnDialogueSelectEval, class USUDSDialogue* /*Dialogue*/, const FString& /*ConditionString*/, bool /*bResult*/, int /*SourceLineNo*/);
#endif
DECLARE_LOG_CATEGORY_EXTERN(LogSUDSDialogue, Verbose, All);
/// Copy of the internal state of a dialogue
USTRUCT(BlueprintType)
struct FSUDSDialogueState
{
GENERATED_BODY()
protected:
UPROPERTY(BlueprintReadOnly, SaveGame, Category="SUDS|Dialogue")
FString TextNodeID;
UPROPERTY(BlueprintReadOnly, SaveGame, Category="SUDS|Dialogue")
TMap<FName, FSUDSValue> Variables;
UPROPERTY(BlueprintReadOnly, SaveGame, Category="SUDS|Dialogue")
TArray<FString> ChoicesTaken;
UPROPERTY(BlueprintReadOnly, SaveGame, Category="SUDS|Dialogue")
TArray<FString> ReturnStack;
public:
FSUDSDialogueState() {}
FSUDSDialogueState(const FString& TxtID,
const TMap<FName, FSUDSValue>& InVars,
const TSet<FString>& InChoices,
const TArray<FString>& InReturnStack) : TextNodeID(TxtID),
Variables(InVars),
ChoicesTaken(InChoices.Array()),
ReturnStack(InReturnStack)
{
}
const FString& GetTextNodeID() const { return TextNodeID; }
const TMap<FName, FSUDSValue>& GetVariables() const { return Variables; }
const TArray<FString>& GetChoicesTaken() const { return ChoicesTaken; }
const TArray<FString>& GetReturnStack() const { return ReturnStack; }
SUDS_API friend FArchive& operator<<(FArchive& Ar, FSUDSDialogueState& Value);
SUDS_API friend void operator<<(FStructuredArchive::FSlot Slot, FSUDSDialogueState& Value);
bool Serialize(FStructuredArchive::FSlot Slot)
{
Slot << *this;
return true;
}
bool Serialize(FArchive& Ar)
{
Ar << *this;
return true;
}
};
/**
* A Dialogue is a runtime instance of a Script (the asset on which the dialogue is based)
* An Dialogue always stops on a speaker line, which may have player choices. It progresses when you call Continue()
* or Choose() and will run that continuation until it hits the next speaker line. In between, other things may occur
* such as setting variables, raising events etc, depending on the script.
* Each dialogue instance has its own state, so you can invoke the same Script multiple times as different dialogues if you want.
* Each dialogue maintains its own internal state, which includes a set of variables.
* Dialogues can have Participants, which are objects closely involved in the dialogue and which have the best access to
* supply and retrieve variables and get events first. Other objects can simply listen to the exposed events; while they
* can manipulate dialogue state too, they have less controllable access in terms of *when* this happens. It's best to
* have at least one Participant driving state on the dialogue (relaying it to external objects), and to have read-only
* users like UIs use the event delegates instead.
* Dialogues need to be owned by an object, mainly for garbage collection. It's recommended that you set the owner to
* one of the NPCs in the dialogue.
* You can save/restore the state of a dialogue via GetSavedState/RestoreSavedState.
*/
UCLASS(BlueprintType)
class SUDS_API USUDSDialogue : public UObject
{
GENERATED_BODY()
public:
/// Event raised when dialogue progresses and a new speaker line, potentially with new choices, is ready to be displayed
UPROPERTY(BlueprintAssignable)
FOnDialogueSpeakerLine OnSpeakerLine;
/// Event raised when a choice is made in the dialogue by the player. At this point, the dialogue has not progressed
/// as a result of that choice so the index passed can be used to reference the choice
/// This event is ONLY raised if there's a choice of paths, not for just continuing a linear path.
UPROPERTY(BlueprintAssignable)
FOnDialogueChoice OnChoice;
/// Event raised when the dialog is about to proceed away from the current speaker line (because of a choice or continue)
UPROPERTY(BlueprintAssignable)
FOnDialogueProceeding OnProceeding;
/// Event raised when an event is sent from the dialogue script. Any listeners or participants can process the event.
UPROPERTY(BlueprintAssignable)
FOnDialogueEvent OnEvent;
/// Event raised when a variable is changed. "FromScript" is true if the variable was set by the script, false if set from code
UPROPERTY(BlueprintAssignable)
FOnVariableChangedEvent OnVariableChanged;
/// Event raised when a variable is requested by the dialogue script. You can use this hook to set variables in the
/// dialogue on-demand rather than up-front; anything set during this hook will be immediately used by the dialogue
UPROPERTY(BlueprintAssignable)
FOnVariableRequestedEvent OnVariableRequested;
/// Event raised when the dialogue is starting, before the first speaker line
UPROPERTY(BlueprintAssignable)
FOnDialogueStarting OnStarting;
/// Event raised when the dialogue finishes
UPROPERTY(BlueprintAssignable)
FOnDialogueFinished OnFinished;
protected:
UPROPERTY()
TObjectPtr<const USUDSScript> BaseScript;
UPROPERTY()
TObjectPtr<USUDSScriptNodeText> CurrentSpeakerNode;
UPROPERTY()
TObjectPtr<const USUDSScriptNode> CurrentRootChoiceNode;
/// External objects which want to closely participate in the dialogue (not just listen to events)
UPROPERTY()
TArray<TObjectPtr<UObject>> Participants;
/// All of the dialogue variables
/// Dialogue variable state is all held locally. Dialogue participants can retrieve or set values in state.
/// All state is saved with the dialogue. Variables can be used as text substitution parameters, conditionals,
/// or communication with external state.
typedef TMap<FName, FSUDSValue> FSUDSValueMap;
FSUDSValueMap VariableState;
/// Stack of Gosub nodes to return to
UPROPERTY()
TArray<TObjectPtr<USUDSScriptNodeGosub>> GosubReturnStack;
/// Set of all the TextIDs of choices taken already in this dialogue
TSet<FString> ChoicesTaken;
TSet<FName> CurrentRequestedParamNames;
bool bParamNamesExtracted;
/// Cached derived info
mutable FText CurrentSpeakerDisplayName;
/// All valid choices
TArray<FSUDSScriptEdge> CurrentChoices;
int CurrentSourceLineNo;
static const FText DummyText;
static const FString DummyString;
void InitVariables();
void RunUntilNextSpeakerNodeOrEnd(USUDSScriptNode* FromNode, bool bRaiseAtEnd);
const USUDSScriptNode* WalkToNextChoiceNode(USUDSScriptNode* FromNode, bool bExecute);
USUDSScriptNode* RecurseWalkToNextChoiceOrTextNode(USUDSScriptNode* Node, bool bExecute, TArray<TObjectPtr<USUDSScriptNodeGosub>>& LocalGosubStack);
const USUDSScriptNode* RunUntilNextChoiceNode(USUDSScriptNode* FromTextNode);
const USUDSScriptNode* FindNextChoiceNode(USUDSScriptNode* FromNode);
void SetCurrentSpeakerNode(USUDSScriptNodeText* Node, bool bQuietly);
void SortParticipants();
void RaiseStarting(FName StartLabel);
void RaiseFinished();
void RaiseNewSpeakerLine();
void RaiseChoiceMade(int Index, int LineNo);
void RaiseProceeding();
void RaiseVariableChange(const FName& VarName, const FSUDSValue& Value, bool bFromScript, int LineNo);
void RaiseVariableRequested(const FName& VarName, int LineNo);
void RaiseExpressionVariablesRequested(const FSUDSExpression& Expression, int LineNo);
const TMap<FName, FSUDSValue>& GetGlobalVariables() const;
USUDSScriptNode* GetNextNode(USUDSScriptNode* Node);
bool IsChoiceOrTextNode(ESUDSScriptNodeType Type);
USUDSScriptNode* RunNode(USUDSScriptNode* Node);
USUDSScriptNode* RunSelectNode(USUDSScriptNode* Node);
USUDSScriptNode* RunSetVariableNode(USUDSScriptNode* Node);
USUDSScriptNode* RunEventNode(USUDSScriptNode* Node);
USUDSScriptNode* RunGosubNode(USUDSScriptNode* Node);
USUDSScriptNode* RunReturnNode(USUDSScriptNode* Node);
void UpdateChoices();
void RecurseAppendChoices(const USUDSScriptNode* Node, TArray<FSUDSScriptEdge>& OutChoices);
USoundBase* GetSoundForCurrentLine(bool bAllowAnyTarget) const;
UDialogueVoice* GetTargetVoice() const;
class USoundConcurrency* GetVoiceSoundConcurrency() const;
FText ResolveParameterisedText(const TArray<FName> Params, const FTextFormat& TextFormat, int LineNo);
void GetTextFormatArgs(const TArray<FName>& ArgNames, FFormatNamedArguments& OutArgs) const;
bool CurrentNodeHasChoices() const;
void SetVariableImpl(FName Name, const FSUDSValue& Value, bool bFromScript, int LineNo)
{
const FSUDSValue OldValue = GetVariable(Name);
if (!IsVariableSet(Name) ||
(OldValue != Value).GetBooleanValue())
{
VariableState.Add(Name, Value);
RaiseVariableChange(Name, Value, bFromScript, LineNo);
}
}
public:
USUDSDialogue();
// virtual ~USUDSDialogue() override
// {
// UE_LOG(LogTemp, Warning, TEXT("*********** Destroyed Dialogue!"));
// }
void Initialise(const USUDSScript* Script);
/// Get the script asset this dialogue is based on
UFUNCTION(BlueprintCallable, BlueprintPure, Category="SUDS|Dialogue")
const USUDSScript* GetScript() const { return BaseScript; }
/**
* Begin the dialogue. Make sure you've added all participants before calling this.
* This may not be the first time you've started this dialogue. All previous state is maintained to enable you
* for example to take branching paths based on whether you've spoken to this character before.
* If you want to reset *all* state, call Restart(true). However this is an extreme case; if you want to just
* reset some variables then use the header section of the script to set variables to a default starting point.
* @param Label The start point for this dialogue. If None, starts from the beginning.
*/
UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue")
void Start(FName Label = NAME_None);
/**
* Add a participant to this dialogue instance.
* Participants are objects which want to be more closely involved in the dialogue. As opposed to event listeners,
* participants get advance notice of events in the dialogue, and are also called in a known order, determined by
* their priority. If you're providing variables to the dialogue, it is best to do it as a participant since it
* gives you much more control.
* @param Participant The participant object, which must implement ISUDSParticipant
*/
UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue")
void AddParticipant(UObject* Participant);
/// Retrieve participants from this dialogue
UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue")
const TArray<UObject*>& GetParticipants() const { return ObjectPtrDecay(Participants); }
/**
* Set the complete list of participants for this dialogue instance.
* Participants are objects which want to be more closely involved in the dialogue. As opposed to event listeners,
* participants get advance notice of events in the dialogue, and are also called in a known order, determined by
* their priority. If you're providing variables to the dialogue, it is best to do it as a participant since it
* gives you much more control.
* @param NewParticipants List of new participants. Each should implement ISUDSParticipant
*/
UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue")
void SetParticipants(const TArray<UObject*>& NewParticipants);
/// Get the speech text for the current dialogue node
/// Any parameters required will be requested from participants in the dialogue and replaced
UFUNCTION(BlueprintCallable, BlueprintPure, Category="SUDS|Dialogue")
FText GetText();
/// Get the DialogueWave associated with the current dialogue node
/// Returns null if there is no wave for this line.
UFUNCTION(BlueprintCallable, BlueprintPure, Category="SUDS|Dialogue")
UDialogueWave* GetWave() const;
/// Return whether the current dialogue node has a Dialogue Wave associated with it
UFUNCTION(BlueprintCallable, BlueprintPure, Category="SUDS|Dialogue")
bool IsCurrentLineVoiced() const;
/// Get the ID of the current speaker
UFUNCTION(BlueprintCallable, BlueprintPure, Category="SUDS|Dialogue")
const FString& GetSpeakerID() const;
/// Get the display name of the current speaker
UFUNCTION(BlueprintCallable, BlueprintPure, Category="SUDS|Dialogue")
FText GetSpeakerDisplayName() const;
/// Get the Dialogue Voice belonging to the current speaker, if voiced (Null otherwise)
UFUNCTION(BlueprintCallable, BlueprintPure, Category="SUDS|Dialogue")
UDialogueVoice* GetSpeakerVoice() const;
/// Get the Dialogue Voice belonging to the named participant, if voiced (Null otherwise)
UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue")
UDialogueVoice* GetVoice(FString Name) const;
/** If the current line is voiced, plays it in 2D.
* @param VolumeMultiplier A linear scalar multiplied with the volume, in order to make the sound louder or softer.
* @param PitchMultiplier A linear scalar multiplied with the pitch.
* @param bLooselyMatchTarget When finding the sound, don't require the target DialogueVoice to match precisely (recommended)
*/
UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue", meta=(AdvancedDisplay = "2", UnsafeDuringActorConstruction = "true", Keywords = "play"))
void PlayVoicedLine2D(float VolumeMultiplier = 1.f, float PitchMultiplier = 1.f, bool bLooselyMatchTarget = true);
/** If the current line is voiced, plays it at the given location.
* @param Location World position to play dialogue at
* @param Rotation World rotation to play dialogue at
* @param VolumeMultiplier A linear scalar multiplied with the volume, in order to make the sound louder or softer.
* @param PitchMultiplier A linear scalar multiplied with the pitch.
* @param AttenuationSettings Override attenuation settings package to play sound with
* @param bLooselyMatchTarget When finding the sound, don't require the target DialogueVoice to match precisely (recommended)
*/
UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue", meta=(AdvancedDisplay = "4", UnsafeDuringActorConstruction = "true", Keywords = "play"))
void PlayVoicedLineAtLocation(FVector Location,
FRotator Rotation,
float VolumeMultiplier = 1.f,
float PitchMultiplier = 1.f,
USoundAttenuation* AttenuationSettings = nullptr,
bool bLooselyMatchTarget = true);
/** If the current line is voiced, spawn a sound for it in 2D. Use this if you want to control the sound while it's playing.
* @param VolumeMultiplier A linear scalar multiplied with the volume, in order to make the sound louder or softer.
* @param PitchMultiplier A linear scalar multiplied with the pitch.
* @param bLooselyMatchTarget When finding the sound, don't require the target DialogueVoice to match precisely (recommended)
*/
UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue", meta=(AdvancedDisplay = "2", UnsafeDuringActorConstruction = "true", Keywords = "play"))
UAudioComponent* SpawnVoicedLine2D(float VolumeMultiplier = 1.f, float PitchMultiplier = 1.f, bool bLooselyMatchTarget = true);
/** If the current line is voiced, spawn a sound for it at the given location. Unlike PlayVoicedLineAtLocation you can
* attach this sound to a moving object if you want
* @param Location World position to play dialogue at
* @param Rotation World rotation to play dialogue at
* @param VolumeMultiplier A linear scalar multiplied with the volume, in order to make the sound louder or softer.
* @param PitchMultiplier A linear scalar multiplied with the pitch.
* @param AttenuationSettings Override attenuation settings package to play sound with
* @param bLooselyMatchTarget When finding the sound, don't require the target DialogueVoice to match precisely (recommended)
*/
UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue", meta=(AdvancedDisplay = "4", UnsafeDuringActorConstruction = "true", Keywords = "play"))
UAudioComponent* SpawnVoicedLineAtLocation(FVector Location,
FRotator Rotation,
float VolumeMultiplier = 1.f,
float PitchMultiplier = 1.f,
USoundAttenuation* AttenuationSettings = nullptr,
bool bLooselyMatchTarget = true);
/** If the current line is voiced, get the sound which would be played for it.
* @param bLooselyMatchTarget When finding the sound, don't require the target DialogueVoice to match precisely (recommended)
*/
UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue", meta=(AdvancedDisplay = "4", UnsafeDuringActorConstruction = "true", Keywords = "play"))
USoundBase* GetVoicedLineSound(bool bLooselyMatchTarget = true);
/**
* Get the number of choices available from this node.
* Note, this will return 1 in the case of just linear text progression. The difference between just linked text
* lines and a choice with only 1 option is whether the choice text is blank or not.
* See also IsSimpleContinue()
* @return The number of choices available
*/
UFUNCTION(BlueprintCallable, BlueprintPure, Category="SUDS|Dialogue")
int GetNumberOfChoices() const;
/**
* Return whether to progress from here is a simple continue (no choices, no text), meaning you probably want
* to display a simpler prompt to the player.
* This will return false even if there's only one choice, if that choice has text associated with it.
*/
UFUNCTION(BlueprintCallable, BlueprintPure, Category="SUDS|Dialogue")
bool IsSimpleContinue() const;
/**
* Get the text associated with a choice.
* @param Index The index of the choice
* @return The text. This may be blank if this represents just a link between 2 nodes and not a choice at all.
* Note that if you want to have only 1 choice but with associated text, this is fine and should be a choice
* line just like any other.
*/
UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue")
FText GetChoiceText(int Index);
/// Get all the current choices available, if you prefer this format
UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue")
const TArray<FSUDSScriptEdge>& GetChoices() const;
/** Returns whether the choice at the given index has been taken previously.
* This is saved in dialogue state so will be remembered across save/restore.
*/
UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue")
bool HasChoiceIndexBeenTakenPreviously(int Index);
/** Returns whether a choice has been taken previously.
* This is saved in dialogue state so will be remembered across save/restore.
*/
UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue")
bool HasChoiceBeenTakenPreviously(const FSUDSScriptEdge& Choice);
/**
* Continues the dialogue if (and ONLY if) there is only one valid path/choice out of the current node.
* @return True if the dialogue continues after this, false if the dialogue is now at an end.
*/
UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue")
bool Continue();
/**
* Picks one of the available choices
* If there's only 1 you can still call this with Index = 0, but also see Continue
* @param Index The index of the choice to make
* @return True if the dialogue continues, false if it has now reached the end.
*/
UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue")
bool Choose(int Index);
/// Returns true if the dialogue has reached the end
UFUNCTION(BlueprintCallable, BlueprintPure, Category="SUDS|Dialogue")
bool IsEnded() const;
/// Returns whether the current speaker line is the last line of dialogue, i.e. there are no
/// further choices and the next continue will end the dialogue. This allows you to anticipate
/// the end of dialogue (IsEnded() will still return false until the last continue is taken)
UFUNCTION(BlueprintCallable, BlueprintPure, Category="SUDS|Dialogue")
bool IsFinalLine() const;
/// End the dialogue early
UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue")
void End(bool bQuietly);
/// Get the source line number of the current position of the dialogue (returns 0 if not applicable)
UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue")
int GetCurrentSourceLine() const;
/**
* Restart the dialogue, either from the start or from a named label.
* @param bResetState Whether to reset ALL dialogue state, as if the dialogue had been created anew. You mostly don't want
* to do this; if you have certain things you want to reset every time, then use [set] commands in the header section
* which runs every time the dialogue starts.
* @param StartLabel Label to start running from; if None start from the beginning.
* @param bReRunHeader If true (default), re-runs the header nodes before starting. Header nodes let you initialise
* state that should always be reset when the dialogue is restarted
*/
UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue")
void Restart(bool bResetState = false, FName StartLabel = NAME_None, bool bReRunHeader = true);
/**
* Reset the state of this dialogue.
* @param bResetVariables If true, resets all variable state
* @param bResetPosition If true, resets the current position in the dialogue (which speaker line is next)
* @param bResetVisited If true, resets the memory of which choices have been made
*/
UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue")
void ResetState(bool bResetVariables = true, bool bResetPosition = true, bool bResetVisited = true);
/** Retrieve a copy of the state of this dialogue.
* This is useful for saving the state of this dialogue.
* @return A static copy of the current state of this dialogue. This struct can be serialised with your save data,
* and contains both the state of variables and the current speaking node ID.
* @note If you save/load mid-dialogue then you're need to have written Text ID's into the source text to ensure they
* stay the same between edits, as you do for localisation. If you only save/load after dialogue has ended then
* you don't need to worry about this since the dialogue will always start from the beginning
*/
UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue")
FSUDSDialogueState GetSavedState() const;
/** Restore the saved state of this dialogue.
* This is useful for restoring the state of this dialogue. It will attempt to restore both the value of variables,
* and the current speaking node in the dialogue. If you expect to be able to restore to a point mid-dialogue,
* it's important that Text IDs are defined in your source file (as for localisation) since that's used as the
* identifier of the current speaking node. If you only save/load after dialogue has ended then you don't need
* to worry about this as dialogue will restart each time.
* @param State Dialogue state that you previously retrieved from GetSavedState().
* @note After restoring, you'll want to either call Start() or Continue(), depending on whether you restored
* mid-dialogue or not (see IsEnded() to tell whether you did)
*/
UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue")
void RestoreSavedState(const FSUDSDialogueState& State);
/// Get the set of text parameters that are actually being asked for in the current state of the dialogue.
/// This will include parameters in the text, and parameters in any current choices being displayed.
/// Use this if you want to be more specific about what parameters you supply when ISUDSParticipant::UpdateDialogueParameters
/// is called.
UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue")
TSet<FName> GetParametersInUse();
/// Set a variable in dialogue state
/// This is mostly only useful if you happen to already have a general purpose FSUDSValue.
/// See SetVariableText, SetVariableInt etc for literal-friendly versions
UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue")
void SetVariable(FName Name, FSUDSValue Value)
{
SetVariableImpl(Name, Value, false, 0);
}
/// Get a variable in dialogue state as a general value type
/// See GetDialogueText, GetDialogueInt etc for more type friendly versions, but if you want to access the state
/// as a type-flexible value then you can do so with this function.
UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue")
FSUDSValue GetVariable(FName Name) const
{
if (const auto Arg = VariableState.Find(Name))
{
return *Arg;
}
return FSUDSValue();
}
UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue")
bool IsVariableSet(FName Name) const
{
return VariableState.Contains(Name);
}
/// Get all variables
UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue")
const TMap<FName, FSUDSValue>& GetVariables() const { return VariableState; }
/**
* Set a text dialogue variable
* @param Name The name of the variable
* @param Value The value of the variable
*/
UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue")
void SetVariableText(FName Name, FText Value)
{
SetVariable(Name, Value);
}
/**
* Get a text dialogue variable
* @param Name The name of the variable
* @returns Value The value of the variable
*/
UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue")
FText GetVariableText(FName Name) const;
/**
* Set a dialogue variable on the passed in parameters collection.
* @param Name The name of the variable
* @param Value The value of the variable
*/
UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue")
void SetVariableInt(FName Name, int32 Value);
/**
* Get an int dialogue variable
* @param Name The name of the variable
* @returns Value The value of the variable
*/
UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue")
int GetVariableInt(FName Name) const;
/**
* Set a float dialogue variable
* @param Name The name of the variable
* @param Value The value of the variable
*/
UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue")
void SetVariableFloat(FName Name, float Value);
/**
* Get a float dialogue variable
* @param Name The name of the variable
* @returns Value The value of the variable
*/
UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue")
float GetVariableFloat(FName Name) const;
/**
* Set a gender dialogue variable
* @param Name The name of the variable
* @param Value The value of the variable
*/
UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue")
void SetVariableGender(FName Name, ETextGender Value);
/**
* Get a gender dialogue variable
* @param Name The name of the variable
* @returns Value The value of the variable
*/
UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue")
ETextGender GetVariableGender(FName Name) const;
/**
* Set a boolean dialogue variable
* @param Name The name of the variable
* @param Value The value of the variable
*/
UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue")
void SetVariableBoolean(FName Name, bool Value);
/**
* Get a boolean dialogue variable
* @param Name The name of the variable
* @returns Value The value of the variable
*/
UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue")
bool GetVariableBoolean(FName Name) const;
/**
* Set a name dialogue variable
* @param Name The name of the variable
* @param Value The value of the variable
*/
UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue")
void SetVariableName(FName Name, FName Value);
/**
* Get a name dialogue variable
* @param Name The name of the variable
* @returns Value The value of the variable
*/
UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue")
FName GetVariableName(FName Name) const;
/**
* Remove the definition of a variable.
* This has much same effect as setting the variable back to the default value for this type, since attempting to
* retrieve a missing variable result in a default value.
* @param Name The name of the variable
*/
UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue")
void UnSetVariable(FName Name);
/**
* Get a piece of user-specified metadata for the current speaker line.
* User metadata is assigned by adding a special comment before a speaker line, and can be used
* for anything where you need additional data associated with a particular line (as opposed to
* setting a variable or sending an event).
* @param Key The metadata key
* @return The current metadata value
*/
UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue")
FSUDSValue GetSpeakerLineUserMetadata(FName Key) const;
/**
* Get all user-specified metadata for the current speaker line.
* User metadata is assigned by adding a special comment before a speaker line, and can be used
* for anything where you need additional data associated with a particular line (as opposed to
* setting a variable or sending an event).
* @return All key/value user metadata for the current speaker line
*/
UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue")
TMap<FName, FSUDSValue> GetAllSpeakerLineUserMetadata() const;
/**
* Get a piece of user-specified metadata for a choice in the current list of choices.
* User metadata is assigned by adding a special comment before a choice line, and can be used
* for anything where you need additional data associated with a particular choice, such as RPG
* stat requirements.
* @param Index The choice index
* @param Key The metadata key
* @return The current metadata value
*/
UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue")
FSUDSValue GetChoiceUserMetadata(int Index, FName Key) const;
/**
* Get all user-specified metadata for a choice in the current list of choices.
* User metadata is assigned by adding a special comment before a choice line, and can be used
* for anything where you need additional data associated with a particular choice, such as RPG
* stat requirements.
* @param Index The choice index
* @return All key/value user metadata for the choice
*/
UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue")
TMap<FName, FSUDSValue> GetAllChoiceUserMetadata(int Index) const;
#if WITH_EDITOR
FOnDialogueSpeakerLineInternal InternalOnSpeakerLine;
FOnDialogueChoiceInternal InternalOnChoice;
FOnDialogueProceedingInternal InternalOnProceeding;
FOnDialogueEventInternal InternalOnEvent;
FOnDialogueVarChangedByScriptInternal InternalOnSetVar;
FOnDialogueVarChangedByCodeInternal InternalOnSetVarByCode;
FOnDialogueSelectEval InternalOnSelectEval;
FOnDialogueStartingInternal InternalOnStarting;
FOnDialogueFinishedInternal InternalOnFinished;
#endif
};

View File

@@ -0,0 +1,234 @@
// Copyright Steve Streeting 2022
// Released under the MIT license https://opensource.org/license/MIT/
#pragma once
#include "SUDSValue.h"
#include "SUDSExpression.generated.h"
UENUM(BlueprintType)
enum class ESUDSExpressionItemType : uint8
{
Null = 0 UMETA(Hidden),
// Operators (must be 0-127, in order of precedence, highest first - gaps left in case we need them)
Not = 4,
Multiply = 10,
Divide = 11,
Modulo = 12,
Add = 20,
Subtract = 21,
Less = 30,
LessEqual = 31,
Greater = 32,
GreaterEqual = 33,
Equal = 34,
NotEqual = 35,
And = 40,
Or = 41,
LParens = 100,
RParens = 101,
// Operands (must be 128+)
Operand = 128
};
/// An item in an expression queue, can be operator or operand
USTRUCT(BlueprintType)
struct SUDS_API FSUDSExpressionItem
{
GENERATED_BODY()
protected:
UPROPERTY(BlueprintReadOnly, Category="SUDS|Expression")
ESUDSExpressionItemType Type;
// Value if an operand node
UPROPERTY(BlueprintReadOnly, Category="SUDS|Expression")
FSUDSValue OperandValue;
public:
FSUDSExpressionItem() : Type(ESUDSExpressionItemType::Operand) {}
FSUDSExpressionItem(ESUDSExpressionItemType Operator) : Type(Operator) {}
FSUDSExpressionItem(const FSUDSValue& LiteralOrVariable)
: Type(ESUDSExpressionItemType::Operand),
OperandValue(LiteralOrVariable)
{
}
ESUDSExpressionItemType GetType() const { return Type; }
// Only valid if optype is operand
const FSUDSValue& GetOperandValue() const { return OperandValue; }
void SetOperandValue(const FSUDSValue& NewVal) { OperandValue = NewVal; }
bool IsOperator() const { return static_cast<uint8>(Type) < 128; }
bool IsOperand() const { return !IsOperator(); }
bool IsBinaryOperator() const
{
// Only not is unary right now
return Type != ESUDSExpressionItemType::Not;
}
};
/// An expression holds an executable expression, whether it's a simple single literal
/// or a compound expression with variables
USTRUCT(BlueprintType)
struct SUDS_API FSUDSExpression
{
GENERATED_BODY()
protected:
// The output queue in Reverse Polish Notation order
UPROPERTY()
TArray<FSUDSExpressionItem> Queue;
/// Whether the tree is valid to execute
UPROPERTY(BlueprintReadOnly, Category="SUDS|Expression")
bool bIsValid;
UPROPERTY()
TArray<FName> VariableNames;
/// The original string version of the expression, for reference
UPROPERTY(BlueprintReadOnly, Category="SUDS|Expression")
FString SourceString;
FSUDSExpressionItem EvaluateOperator(ESUDSExpressionItemType Op,
const FSUDSExpressionItem& Arg1,
const FSUDSExpressionItem& Arg2,
const TMap<FName, FSUDSValue>& Variables,
const TMap<FName, FSUDSValue>& GlobalVariables) const;
FSUDSValue EvaluateOperand(const FSUDSValue& Operand, const TMap<FName, FSUDSValue>& Variables, const TMap<FName, FSUDSValue>& GlobalVariables) const;
bool Validate();
public:
FSUDSExpression() : bIsValid(true) {}
/// Initialise an expression tree just with a single literal or variable
FSUDSExpression(const FSUDSValue& LiteralOrVariable)
{
Queue.Add(FSUDSExpressionItem(LiteralOrVariable));
if (LiteralOrVariable.IsVariable())
VariableNames.Add(LiteralOrVariable.GetVariableNameValue());
bIsValid = true;
}
/**
* Attempt to parse an expression from a string
* @param Expression The string to parse
* @param OutParseError If there are any errors, pointer to a string to complete with the details
* @return Whether the parsing was successful
*/
bool ParseFromString(const FString& Expression, FString* OutParseError);
/// Reset the expression to return true
void Reset();
/// Evaluate the expression and return the result, using a given variable state
FSUDSValue Evaluate(const TMap<FName, FSUDSValue>& Variables, const TMap<FName, FSUDSValue>& GlobalVariables) const;
/// Evaluate the expression and return the result as a boolean, using a given variable state
bool EvaluateBoolean(const TMap<FName, FSUDSValue>& Variables, const TMap<FName, FSUDSValue>& GlobalVariables, const FString& ErrorContext) const;
/// Get the original source of the expression as a string
const FString& GetSourceString() const { return SourceString; }
/// Whether this expression can be run (or is empty)
bool IsValid() const { return bIsValid; }
/// Whether this expression is blank
bool IsEmpty() const { return Queue.IsEmpty(); }
/// Get the list of variables this expression needs
const TArray<FName>& GetVariableNames() const { return VariableNames; }
/// Return whether this expression is a generated random condition
bool IsRandomCondition() const;
/**
* Attempt to parse an operand from a string. Returns true if this string is a valid operand, which means a literal
* (int, float, quoted string, boolean, gender), or a variable reference ({VariableName})
* @param ValueStr The string to parse
* @param OutVal The operand value which will be populated if successful
* @return True if successful, false if not
*/
static bool ParseOperand(const FString& ValueStr, FSUDSValue& OutVal);
// Attempt to parse an operator from an incoming string
static ESUDSExpressionItemType ParseOperator(const FString& OpStr);
/// Access the internal RPN execution queue
const TArray<FSUDSExpressionItem>& GetQueue() { return Queue; }
/// Return whether this is a single literal
bool IsLiteral() const
{
return bIsValid && Queue.Num() == 1 && Queue[0].IsOperand() && Queue[0].GetOperandValue().GetType() != ESUDSValueType::Variable;
}
/// Helper method to get literal values
FSUDSValue GetLiteralValue() const
{
check(IsLiteral());
return Queue[0].GetOperandValue();
}
/// Return whenter this is a text literal
bool IsTextLiteral() const
{
return bIsValid && Queue.Num() == 1 && Queue[0].IsOperand() && Queue[0].GetOperandValue().GetType() == ESUDSValueType::Text;
}
/// Helper method to get a text literal value, for easier localisation
FText GetTextLiteralValue() const
{
check(IsTextLiteral());
return GetLiteralValue().GetTextValue();
}
/// Helper method to override a text literal
void SetTextLiteralValue(const FText& NewLiteral)
{
check(IsTextLiteral());
Queue[0].SetOperandValue(NewLiteral);
}
/// Helper method to get boolean literal value
bool GetBooleanLiteralValue() const
{
check(IsLiteral() && GetLiteralValue().GetType() == ESUDSValueType::Boolean);
return GetLiteralValue().GetBooleanValue();
}
/// Helper method to get int literal value
int GetIntLiteralValue() const
{
check(IsLiteral() && GetLiteralValue().GetType() == ESUDSValueType::Int);
return GetLiteralValue().GetIntValue();
}
/// Helper method to get float literal value
float GetFloatLiteralValue() const
{
check(IsLiteral() && GetLiteralValue().GetType() == ESUDSValueType::Float);
return GetLiteralValue().GetFloatValue();
}
/// Helper method to get gender literal value
ETextGender GetGenderLiteralValue() const
{
check(IsLiteral() && GetLiteralValue().GetType() == ESUDSValueType::Gender);
return GetLiteralValue().GetGenderValue();
}
/// Helper method to get name literal value
FName GetNameLiteralValue() const
{
check(IsLiteral() && GetLiteralValue().GetType() == ESUDSValueType::Name);
return GetLiteralValue().GetNameValue();
}
};

View File

@@ -0,0 +1,145 @@
// Copyright Steve Streeting 2022
// Released under the MIT license https://opensource.org/license/MIT/
#pragma once
#include "CoreMinimal.h"
#include "SUDSValue.h"
#include "Kismet/BlueprintFunctionLibrary.h"
#include "SUDSLibrary.generated.h"
class USUDSScript;
class USUDSDialogue;
UCLASS()
class SUDS_API USUDSLibrary : public UBlueprintFunctionLibrary
{
GENERATED_BODY()
public:
/**
* Create a dialogue instance based on a script, with no participants.
* You should subsequently call "SetParticipants" or "AddParticipant" on the returned dialogue if you expect any
* parameters or speaker names to work.
* @param Owner The owner of this instance. Can be any object but determines the lifespan of this dialogue,
* could make sense to make the owner the NPC you're talking to for example.
* @param Script The script to base this dialogue on
* @param bStartImmediately Whether to call Start() on the dialogue automatically before returning
* @param StartLabel If set to start immediately, which label to start from (None means start from the beginning)
* @return The dialogue instance.
*/
UFUNCTION(BlueprintCallable, Category="SUDS")
static USUDSDialogue* CreateDialogue(UObject* Owner,
USUDSScript* Script,
bool bStartImmediately = false,
FName StartLabel = NAME_None);
/**
* Create a dialogue instance based on a script, with an initial set of participants.
* @param Owner The owner of this instance. Can be any object but determines the lifespan of this dialogue,
* could make sense to make the owner the NPC you're talking to for example.
* @param Script The script to base this dialogue on
* @param Participants List of participants, each of which must implement the ISUDSParticipant interface to be used.
* Participants are objects that want to be closely involved in the dialogue to provide variables and receive all events.
* Other objects can subscribe to events separately but do not have as much control.
* @param bStartImmediately Whether to call Start() on the dialogue automatically before returning
* @param StartLabel If set to start immediately, which label to start from (None means start from the beginning)
* @return The dialogue instance.
*/
UFUNCTION(BlueprintCallable, Category="SUDS")
static USUDSDialogue* CreateDialogueWithParticipants(UObject* Owner,
USUDSScript* Script,
const TArray<UObject*>& Participants,
bool bStartImmediately = false,
FName StartLabel = NAME_None);
/**
* Create a dialogue instance based on a script, with a single participants.
* @param Owner The owner of this instance. Can be any object but determines the lifespan of this dialogue,
* could make sense to make the owner the NPC you're talking to for example.
* @param Script The script to base this dialogue on
* @param Participant The participant, which must implement the ISUDSParticipant interface to be used.
* Participants are objects that want to be closely involved in the dialogue to provide variables and receive all events.
* Other objects can subscribe to events separately but do not have as much control.
* @param bStartImmediately Whether to call Start() on the dialogue automatically before returning
* @param StartLabel If set to start immediately, which label to start from (None means start from the beginning)
* @return The dialogue instance.
*/
UFUNCTION(BlueprintCallable, Category="SUDS")
static USUDSDialogue* CreateDialogueWithParticipant(UObject* Owner,
USUDSScript* Script,
UObject* Participant,
bool bStartImmediately = false,
FName StartLabel = NAME_None);
/**
* Try to extract a text value from a general SUDS value.
* @param Value The SUDS value, which may contain many types of value
* @param TextValue The text value
* @return True if the value was of type text and extracted correctly. False if not.
*/
UFUNCTION(BlueprintCallable, Category="SUDS")
static UPARAM(DisplayName="Success") bool GetDialogueValueAsText(const FSUDSValue& Value, FText& TextValue);
/**
* Try to extract a boolean value from a general SUDS value.
* @param Value The SUDS value, which may contain many types of value
* @param BoolValue The boolean value
* @return True if the value was of type boolean and extracted correctly. False if not.
*/
UFUNCTION(BlueprintCallable, Category="SUDS")
static UPARAM(DisplayName="Success") bool GetDialogueValueAsBoolean(const FSUDSValue& Value, bool& BoolValue);
/**
* Try to extract an integer value from a general SUDS value.
* @param Value The SUDS value, which may contain many types of value
* @param IntValue The integer value
* @return True if the value was of type integer and extracted correctly. False if not.
*/
UFUNCTION(BlueprintCallable, Category="SUDS")
static UPARAM(DisplayName="Success") bool GetDialogueValueAsInt(const FSUDSValue& Value, int& IntValue);
/**
* Try to extract a float value from a general SUDS value.
* @param Value The SUDS value, which may contain many types of value
* @param FloatValue The float value
* @return True if the value was of type float and extracted correctly. False if not.
*/
UFUNCTION(BlueprintCallable, Category="SUDS")
static UPARAM(DisplayName="Success") bool GetDialogueValueAsFloat(const FSUDSValue& Value, float& FloatValue);
/**
* Try to extract a gender value from a general SUDS value.
* @param Value The SUDS value, which may contain many types of value
* @param GenderValue The gender value
* @return True if the value was of type gender and extracted correctly. False if not.
*/
UFUNCTION(BlueprintCallable, Category="SUDS")
static UPARAM(DisplayName="Success") bool GetDialogueValueAsGender(const FSUDSValue& Value, ETextGender& GenderValue);
/**
* Try to extract a Name value from a general SUDS value.
* @param Value The SUDS value, which may contain many types of value
* @param NameValue The Name value
* @return True if the value was of type Name and extracted correctly. False if not.
*/
UFUNCTION(BlueprintCallable, Category="SUDS")
static UPARAM(DisplayName="Success") bool GetDialogueValueAsName(const FSUDSValue& Value, FName& NameValue);
/** Retrieve the type of a SUDS value.
* @param Value The SUDS value, which may contain many types of value
* @return The value type
*/
UFUNCTION(BlueprintCallable, Category="SUDS")
static ESUDSValueType GetDialogueValueType(const FSUDSValue& Value);
/** Determine whether a SUDS value is empty (uninitialised).
* @param Value The SUDS value, which may contain many types of value
* @return Whether the value is empty
*/
UFUNCTION(BlueprintCallable, Category="SUDS")
static bool GetDialogueValueIsEmpty(const FSUDSValue& Value);
/**
* Determine whether a SUDS variable name refers to a global variable
* @param Name The full name of the variable
* @param OutName The name of the variable, trimmed if necessary to remove a global prefix
* @return Whether the variable was global
*/
UFUNCTION(BlueprintCallable, Category="SUDS")
static bool IsDialogueVariableGlobal(const FName& Name, UPARAM(ref) FName& OutName);
};

View File

@@ -0,0 +1,129 @@
// Copyright Steve Streeting 2022
// Released under the MIT license https://opensource.org/license/MIT/
#pragma once
#include "CoreMinimal.h"
#include "UObject/Interface.h"
#include "SUDSValue.h"
#include "SUDSParticipant.generated.h"
class USUDSDialogue;
UINTERFACE(MinimalAPI)
class USUDSParticipant : public UInterface
{
GENERATED_BODY()
};
/**
* Interface to be implemented by participant objects in a given dialogue.
* A participant is simply any object which wants to be closely involved in supplying data to, or retrieving data from,
* the dialogue. Although you could do this simply by subscribing to the delegate events on a dialogue, the advantage
* of making a participant is that you have better control over the ordering of multiple participants, in case for example
* there's some common variable that they both want to set.
* It's also a clearer interface to look for vs ad-hoc delegate hooks.
* Generally we recommend that:
* - Objects providing data to the dialogue should implement ISUDSParticipant
* - Anything that just wants to observe the dialogue (like a UI) should just listen to events
*
* Participants are *guaranteed* to be called earlier than delegates, which means you can set variables from the
* Participant callback and when the UI delegate reads text back, all substitution variables will be up to date.
*
*/
class SUDS_API ISUDSParticipant
{
GENERATED_BODY()
public:
/**
* Called when a dialogue involving this participant is starting.
* The implementation should probably set any starting variables referenced by the dialogue here (or you can do that
* later during other functions). At this point there is no active speaker line, we're bootstrapping.
* @param Dialogue The dialogue
* @param AtLabel The label that the dialogue has started at (None if starting at the beginning)
*/
UFUNCTION(BlueprintCallable, BlueprintNativeEvent, Category="SUDS")
void OnDialogueStarting(USUDSDialogue* Dialogue, FName AtLabel);
/**
* Called when a dialogue finishes.
* @param Dialogue The dialogue
*/
UFUNCTION(BlueprintCallable, BlueprintNativeEvent, Category="SUDS")
void OnDialogueFinished(USUDSDialogue* Dialogue);
/**
* Called when a new speaker line, potentially with attached choices, has become active in the dialogue.
* This participant can provide any variable updates if it needs to at this point.
* Participants will be called before any dialogue event listeners.
* @param Dialogue The dialogue
*/
UFUNCTION(BlueprintCallable, BlueprintNativeEvent, Category="SUDS")
void OnDialogueSpeakerLine(USUDSDialogue* Dialogue);
/**
* Called when a choice is made by the player.
* At this point, the dialogue has not progressed as a result of that choice, so the index passed can be used to
* reference the choice.
* This event is ONLY raised if there's a choice of paths, not for just continuing a linear path.
* See OnDialogueProceeding for a more general callback.
* Participants will be called before any dialogue event listeners.
* @param Dialogue The dialogue
* @param ChoiceIndex The index of the choice that was made
*/
UFUNCTION(BlueprintCallable, BlueprintNativeEvent, Category="SUDS")
void OnDialogueChoiceMade(USUDSDialogue* Dialogue, int ChoiceIndex);
/**
* Called just before proceeding with the dialogue from the current speaker line; just after either a choice is made by the player
* or the dialogue is just prompted to proceed with its single path.
* Participants will be called before any dialogue event listeners.
* @param Dialogue The dialogue
*/
UFUNCTION(BlueprintCallable, BlueprintNativeEvent, Category="SUDS")
void OnDialogueProceeding(USUDSDialogue* Dialogue);
/**
* Called when an event is raised from dialogue
* @param Dialogue The dialogue instance
* @param EventName The name of the event that has been raised
* @param Arguments
*/
UFUNCTION(BlueprintCallable, BlueprintNativeEvent, Category="SUDS")
void OnDialogueEvent(USUDSDialogue* Dialogue, FName EventName, const TArray<FSUDSValue>& Arguments);
/**
* Called when a variable changes value in the dialogue
* @param Dialogue The dialogue instance
* @param VariableName The name of the variable which has changed value
* @param Value The new value
* @param bFromScript True if the value changed because of a script line, false if it changed because of code calling SetVariable
*/
UFUNCTION(BlueprintCallable, BlueprintNativeEvent, Category="SUDS")
void OnDialogueVariableChanged(USUDSDialogue* Dialogue, FName VariableName, const FSUDSValue& Value, bool bFromScript);
/**
* Called when a variable value is requested by the dialogue script.
* While you can set variables on the dialogue at any time and they're persistent, you can implement this method to
* provide on-demand variable values (call SetVariable on the dialogue) if you want. This hook is called just before
* the variables are used.
* @param Dialogue The dialogue instance
* @param VariableName The name of the variable which has changed value
*/
UFUNCTION(BlueprintCallable, BlueprintNativeEvent, Category="SUDS")
void OnDialogueVariableRequested(USUDSDialogue* Dialogue, FName VariableName);
/**
* Return the priority of this participant (default 0).
* If for some reason you need to control the order multiple participants in a dialogue are called,
* override this method; higher priority participants will be called *later* so that their variables etc override
* previously set values.
* @return Relative priority, default 0, higher numbers override lower ones.
*/
UFUNCTION(BlueprintCallable, BlueprintNativeEvent, Category="SUDS")
int GetDialogueParticipantPriority() const;
};

View File

@@ -0,0 +1,114 @@
// Copyright Steve Streeting 2022
// Released under the MIT license https://opensource.org/license/MIT/
#pragma once
#include "CoreMinimal.h"
#include "Runtime/Launch/Resources/Version.h"
#include "Sound/DialogueVoice.h"
#include "UObject/Object.h"
#include "SUDSScript.generated.h"
class USUDSScriptNode;
class USUDSScriptNodeText;
class USUDSScriptNodeGosub;
/**
* A single SUDS script asset.
*/
UCLASS(BlueprintType)
class SUDS_API USUDSScript : public UObject
{
GENERATED_BODY()
protected:
/// Array of nodes (static after import)
UPROPERTY(BlueprintReadOnly, Category="SUDS")
TArray<TObjectPtr<USUDSScriptNode>> Nodes;
/// Map of labels to nodes
UPROPERTY(BlueprintReadOnly, VisibleDefaultsOnly, Category="SUDS")
TMap<FName, int> LabelList;
// Header equivalents for startup
UPROPERTY(BlueprintReadOnly, Category="SUDS")
TArray<TObjectPtr<USUDSScriptNode>> HeaderNodes;
UPROPERTY(BlueprintReadOnly, Category="SUDS")
TMap<FName, int> HeaderLabelList;
/// Array of all speaker IDs found in this script
UPROPERTY(BlueprintReadOnly, VisibleDefaultsOnly, Category="SUDS")
TArray<FString> Speakers;
/// When using VO, Dialogue Voice assets are associated with speaker IDs
UPROPERTY(BlueprintReadOnly, EditDefaultsOnly, Category="SUDS")
TMap<FString, TObjectPtr<UDialogueVoice>> SpeakerVoices;
bool DoesAnyPathAfterLeadToChoice(USUDSScriptNode* FromNode);
int RecurseLookForChoice(USUDSScriptNode* CurrNode);
public:
void StartImport(TArray<TObjectPtr<USUDSScriptNode>>** Nodes,
TArray<TObjectPtr<USUDSScriptNode>>** HeaderNodes,
TMap<FName, int>** LabelList,
TMap<FName, int>** ppHeaderLabelList,
TArray<FString>** SpeakerList);
void FinishImport();
const TArray<USUDSScriptNode*>& GetNodes() const { return ObjectPtrDecay(Nodes); }
const TArray<USUDSScriptNode*>& GetHeaderNodes() const { return ObjectPtrDecay(HeaderNodes); }
const TMap<FName, int>& GetLabelList() const { return LabelList; }
const TMap<FName, int>& GetHeaderLabelList() const { return HeaderLabelList; }
/// Get the first header node, if any (header nodes are run every time the script starts)
UFUNCTION(BlueprintCallable, BlueprintPure, Category="SUDS")
USUDSScriptNode* GetHeaderNode() const;
/// Get the first node of the script, if starting from the beginning
UFUNCTION(BlueprintCallable, BlueprintPure, Category="SUDS")
USUDSScriptNode* GetFirstNode() const;
/// Get the next node after a given node, ONLY if there's only one way to go
UFUNCTION(BlueprintCallable, Category="SUDS")
USUDSScriptNode* GetNextNode(const USUDSScriptNode* Node) const;
/// Get the first node of the script following a label, or null if the label wasn't found
UFUNCTION(BlueprintCallable, Category="SUDS")
USUDSScriptNode* GetNodeByLabel(const FName& Label) const;
/// Try to find a speaker node by its text ID
UFUNCTION(BlueprintCallable, Category="SUDS")
USUDSScriptNodeText* GetNodeByTextID(const FString& TextID) const;
/// Try to find a gosub node by its gosub ID
UFUNCTION(BlueprintCallable, Category="SUDS")
USUDSScriptNodeGosub* GetNodeByGosubID(const FString& ID) const;
/// Get the list of speakers
const TArray<FString>& GetSpeakers() const { return Speakers; }
UFUNCTION(BlueprintCallable, Category="SUDS")
UDialogueVoice* GetSpeakerVoice(const FString& SpeakerID) const;
/// Set up the speaker voice association
void SetSpeakerVoice(const FString& SpeakerID, UDialogueVoice* Voice);
const TMap<FString, UDialogueVoice*>& GetSpeakerVoices() const { return ObjectPtrDecay(SpeakerVoices); }
#if WITH_EDITORONLY_DATA
// Import data for this
UPROPERTY(VisibleAnywhere, Instanced, Category=ImportSettings)
TObjectPtr<class UAssetImportData> AssetImportData;
// UObject interface
virtual void PostInitProperties() override;
#if ENGINE_MAJOR_VERSION == 5 && ENGINE_MINOR_VERSION >= 4
virtual void GetAssetRegistryTags(FAssetRegistryTagsContext Context) const override;
#else
virtual void GetAssetRegistryTags(TArray<FAssetRegistryTag>& OutTags) const override;
#endif
virtual void Serialize(FArchive& Ar) override;
// End of UObject interface
#endif
};

View File

@@ -0,0 +1,88 @@
// Copyright Steve Streeting 2022
// Released under the MIT license https://opensource.org/license/MIT/
#pragma once
#include "CoreMinimal.h"
#include "SUDSExpression.h"
#include "SUDSScriptEdge.generated.h"
class USUDSScriptNode;
UENUM(BlueprintType)
enum class ESUDSEdgeType : uint8
{
/// A simple continuation; usually for sequences with no choices
Continue,
/// A decision made by the player from a list of choices
Decision,
/// A conditional path, taken automatically based on the situation
Condition,
/// An edge which forms a chain of nodes which are supposed to be considered together
/// This links a text node to its choices, and also potentially compound choices underneath if there are selects
Chained
};
/**
* Edge in the script graph. An edge leads to another node (unidirectional)
* Edges can have conditions which mean whether they're valid or not, either as automatic
* choices or player choices.
*/
USTRUCT(BlueprintType)
struct SUDS_API FSUDSScriptEdge
{
GENERATED_BODY()
protected:
// Text, if a user choice. Always references a string table
UPROPERTY(BlueprintReadOnly, Category="SUDS")
FText Text;
UPROPERTY(BlueprintReadOnly, Category="SUDS")
ESUDSEdgeType Type;
UPROPERTY(BlueprintReadOnly, Category="SUDS")
TWeakObjectPtr<USUDSScriptNode> TargetNode;
UPROPERTY(BlueprintReadOnly, Category="SUDS")
FSUDSExpression Condition;
UPROPERTY(BlueprintReadOnly, Category="SUDS")
int SourceLineNo;
/// User metadata associated with this edge. May include derived expressions
UPROPERTY()
TMap<FName, FSUDSExpression> UserMetadata;
mutable bool bFormatExtracted = false;
mutable TArray<FName> ParameterNames;
mutable FTextFormat TextFormat;
void ExtractFormat() const;
public:
FSUDSScriptEdge(): Type(ESUDSEdgeType::Continue), SourceLineNo(0)
{
}
FSUDSScriptEdge(USUDSScriptNode* ToNode, ESUDSEdgeType InType, int LineNo);
FSUDSScriptEdge(const FText& InText, USUDSScriptNode* ToNode, int LineNo);
FText GetText() const { return Text; }
FString GetTextID() const;
ESUDSEdgeType GetType() const { return Type; }
TWeakObjectPtr<USUDSScriptNode> GetTargetNode() const { return TargetNode; }
const FSUDSExpression& GetCondition() const { return Condition; }
int GetSourceLineNo() const { return SourceLineNo; }
const TMap<FName, FSUDSExpression>& GetUserMetadata() const { return UserMetadata; }
void SetText(const FText& Text);
void SetType(ESUDSEdgeType InType) { Type = InType; }
void SetTargetNode(const TWeakObjectPtr<USUDSScriptNode>& InTargetNode);
void SetCondition(const FSUDSExpression& InCondition) { Condition = InCondition; }
void SetUserMetadata(const TMap<FName, FSUDSExpression>& Meta) { UserMetadata = Meta; }
const FTextFormat& GetTextFormat() const;
const TArray<FName>& GetParameterNames() const;
bool HasParameters() const;
};

View File

@@ -0,0 +1,81 @@
// Copyright Steve Streeting 2022
// Released under the MIT license https://opensource.org/license/MIT/
#pragma once
#include "CoreMinimal.h"
#include "SUDSScriptEdge.h"
#include "UObject/Object.h"
#include "SUDSScriptNode.generated.h"
UENUM(BlueprintType)
enum class ESUDSScriptNodeType : uint8
{
/// Text node, displaying a line of dialogue
Text,
/// Choice node, displaying a series of user choices which navigate to other nodes
Choice,
/// Select node, automatically selecting one which navigates to another node based on state
Select,
/// Set variable node
SetVariable,
/// Event node
Event,
/// Gosub node
Gosub,
/// Return node
Return,
};
/**
* A node in the script graph.
* Nodes are either text, or branch points (user choice or automatic branching logic)
* Text nodes always lead to a single next step, be that another text node or a branch.
* Branch nodes are separate from the text so that jumping can return to a choice point without emitting more text.
* At runtime if a text node's next step is a user choice, it will be available immediately. Otherwise it's not
* evaluated until the dialogue progresses.
* Edges connect everything. Edges may have text if they're user choices (even if that's a single choice), and
* may have conditions.
*/
UCLASS(BlueprintType)
class SUDS_API USUDSScriptNode : public UObject
{
GENERATED_BODY()
protected:
/// Type of node
/// To make it easier to check rather than having to cast to subtypes blindly. And also not all types need a subtype
UPROPERTY(BlueprintReadOnly, Category="SUDS")
ESUDSScriptNodeType NodeType = ESUDSScriptNodeType::Text;
/// Links to other nodes
UPROPERTY(BlueprintReadOnly, Category="SUDS")
TArray<FSUDSScriptEdge> Edges;
/// The line number in the script that this node came from
UPROPERTY(BlueprintReadOnly, Category="SUDS")
int SourceLineNo;
public:
USUDSScriptNode();
ESUDSScriptNodeType GetNodeType() const { return NodeType; }
const TArray<FSUDSScriptEdge>& GetEdges() const { return Edges; }
int GetSourceLineNo() const { return SourceLineNo; }
void AddEdge(const FSUDSScriptEdge& NewEdge);
void InitChoice(int LineNo);
void InitSelect(int LineNo);
void InitReturn(int LineNo);
int GetEdgeCount() const { return Edges.Num(); }
const FSUDSScriptEdge* GetEdge(int Index) const
{
if (Edges.IsValidIndex(Index))
{
return &Edges[Index];
}
return nullptr;
}
/// Determine if this node is a Select node that's representing a [random]
bool IsRandomSelect() const;
};

View File

@@ -0,0 +1,33 @@
// Copyright Steve Streeting 2022
// Released under the MIT license https://opensource.org/license/MIT/
#pragma once
#include "CoreMinimal.h"
#include "SUDSExpression.h"
#include "SUDSScriptNode.h"
#include "SUDSScriptNodeEvent.generated.h"
/**
*
*/
UCLASS()
class SUDS_API USUDSScriptNodeEvent : public USUDSScriptNode
{
GENERATED_BODY()
protected:
// Variable identifier
UPROPERTY(BlueprintReadOnly, Category="SUDS")
FName EventName;
/// Literal arguments
UPROPERTY(BlueprintReadOnly, Category="SUDS")
TArray<FSUDSExpression> Args;
public:
void Init(const FString& EvtName, const TArray<FSUDSExpression>& InArgs, int LineNo);
FName GetEventName() const { return EventName; }
const TArray<FSUDSExpression>& GetArgs() const { return Args; }
};

View File

@@ -0,0 +1,49 @@
// Copyright Steve Streeting 2022
// Released under the MIT license https://opensource.org/license/MIT/
#pragma once
#include "CoreMinimal.h"
#include "SUDSScriptNode.h"
#include "SUDSScriptNodeGosub.generated.h"
/**
*
*/
UCLASS()
class SUDS_API USUDSScriptNodeGosub : public USUDSScriptNode
{
GENERATED_BODY()
protected:
/// Name of the label which we'll jump to before returning
UPROPERTY(BlueprintReadOnly, Category="SUDS")
FName LabelName;
/// Generated ID for use when saving state
UPROPERTY(BlueprintReadOnly, Category="SUDS")
FString GosubID;
/// Convenience flag to let you know whether this node MAY HAVE any choices directly after it
/// Internally this also lets us know to look for the next choice node after returning
/// It's possible that where there are conditionals ahead, there are only choices on some of the paths.
/// This flag is to let us know to look for choices, but if conditionals apply we may not find any using actual dialogue state.
UPROPERTY(BlueprintReadOnly, Category="SUDS")
bool bHasChoices = false;
public:
void Init(const FString& Label, const FString ID, int LineNo)
{
NodeType = ESUDSScriptNodeType::Gosub;
LabelName = FName(Label);
GosubID = ID;
SourceLineNo = LineNo;
}
FName GetLabelName() const { return LabelName; }
const FString& GetGosubID() const { return GosubID; }
/// Whether on one select path or another a choice was found
/// Doesn't help if within a Gosub as call site may be anywhere
bool MayHaveChoices() const { return bHasChoices; }
void NotifyMayHaveChoices() { bHasChoices = true; }
};

View File

@@ -0,0 +1,33 @@
// Copyright Steve Streeting 2022
// Released under the MIT license https://opensource.org/license/MIT/
#pragma once
#include "CoreMinimal.h"
#include "SUDSExpression.h"
#include "SUDSScriptNode.h"
#include "SUDSScriptNodeSet.generated.h"
/**
* Set variable node
*/
UCLASS()
class SUDS_API USUDSScriptNodeSet : public USUDSScriptNode
{
GENERATED_BODY()
protected:
// Variable identifier
UPROPERTY(BlueprintReadOnly, Category="SUDS")
FName Identifier;
/// Expression to provide value to set
UPROPERTY(BlueprintReadOnly, Category="SUDS")
FSUDSExpression Expression;
public:
void Init(const FString& VarName, const FSUDSExpression& InExpression, int LineNo);
const FName& GetIdentifier() const { return Identifier; }
const FSUDSExpression& GetExpression() const { return Expression; }
};

View File

@@ -0,0 +1,72 @@
// Copyright Steve Streeting 2022
// Released under the MIT license https://opensource.org/license/MIT/
#pragma once
#include "CoreMinimal.h"
#include "SUDSScriptNode.h"
#include "SUDSScriptNodeText.generated.h"
class UDialogueWave;
/**
* A node which contains speaker text
*/
UCLASS(BlueprintType)
class SUDS_API USUDSScriptNodeText : public USUDSScriptNode
{
GENERATED_BODY()
protected:
/// Identifier of the speaker for text nodes
UPROPERTY(BlueprintReadOnly, VisibleDefaultsOnly, Category="SUDS")
FString SpeakerID;
/// Text, always references a string table. Parameters will not have been completed.
/// Note: if you're using voiced dialogue, see the Wave property and its subtitle functionality
UPROPERTY(BlueprintReadOnly, VisibleDefaultsOnly, Category="SUDS")
FText Text;
/// DialogueWave asset link for voiced dialogue
UPROPERTY(BlueprintReadOnly, EditDefaultsOnly, Category="SUDS")
TObjectPtr<UDialogueWave> Wave;
/// Convenience flag to let you know whether this text node MAY HAVE choices attached
/// If false, there's only one way to proceed from here and no text associated with that
/// If true, either there can be > 1 choice options, or a single choice with associated text (this can be when
/// you have no choice but want text rather than just a continue button)
/// Internally this also lets us know to look for the next choice node
/// It's possible that where there are conditionals ahead, there are only choices on some of the paths.
/// This flag is to let us know to look for choices, but if conditionals apply we may not find any using actual dialogue state.
UPROPERTY(BlueprintReadOnly, Category="SUDS")
bool bHasChoices = false;
/// User metadata associated with this speaker line. May include derived expressions
UPROPERTY()
TMap<FName, FSUDSExpression> UserMetadata;
mutable bool bFormatExtracted = false;
mutable TArray<FName> ParameterNames;
mutable FTextFormat TextFormat;
void ExtractFormat() const;
public:
const FString& GetSpeakerID() const { return SpeakerID; }
const FText& GetText() const { return Text; }
FString GetTextID() const;
UDialogueWave* GetWave() const { return Wave; }
/// Whether on one select path or another a choice was found
/// Doesn't help if within a Gosub as call site may be anywhere
bool MayHaveChoices() const { return bHasChoices; }
void Init(const FString& SpeakerID, const FText& Text, int LineNo);
void SetWave(UDialogueWave* InWave) { Wave = InWave; }
const FTextFormat& GetTextFormat() const;
const TArray<FName>& GetParameterNames() const;
bool HasParameters() const;
void NotifyMayHaveChoices() { bHasChoices = true; }
const TMap<FName, FSUDSExpression>& GetUserMetadata() const { return UserMetadata; }
void SetUserMetadata(const TMap<FName, FSUDSExpression>& Meta) { UserMetadata = Meta; }
};

View File

@@ -0,0 +1,292 @@
// Copyright Steve Streeting 2022
// Released under the MIT license https://opensource.org/license/MIT/
#pragma once
#include "CoreMinimal.h"
#include "SUDSValue.h"
#include "Subsystems/GameInstanceSubsystem.h"
#include "Engine/World.h"
#include "Engine/GameInstance.h"
#include "SUDSSubsystem.generated.h"
class USUDSDialogue;
class USUDSScript;
class USoundConcurrency;
struct FSoundConcurrencySettings;
DECLARE_LOG_CATEGORY_EXTERN(LogSUDSSubsystem, Log, All);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_ThreeParams(FOnGlobalVariableChangedEvent, FName, VariableName, const FSUDSValue&, Value, bool, bFromScript);
/// Copy of the global state of the system
USTRUCT(BlueprintType)
struct FSUDSGlobalState
{
GENERATED_BODY()
protected:
UPROPERTY(BlueprintReadOnly, SaveGame, Category="SUDS")
TMap<FName, FSUDSValue> GlobalVariables;
public:
FSUDSGlobalState() {}
FSUDSGlobalState(const TMap<FName, FSUDSValue>& InGlobalVars) : GlobalVariables(InGlobalVars)
{
}
const TMap<FName, FSUDSValue>& GetGlobalVariables() const { return GlobalVariables; }
SUDS_API friend FArchive& operator<<(FArchive& Ar, FSUDSGlobalState& Value);
SUDS_API friend void operator<<(FStructuredArchive::FSlot Slot, FSUDSGlobalState& Value);
bool Serialize(FStructuredArchive::FSlot Slot)
{
Slot << *this;
return true;
}
bool Serialize(FArchive& Ar)
{
Ar << *this;
return true;
}
};
/**
*
*/
UCLASS()
class SUDS_API USUDSSubsystem : public UGameInstanceSubsystem
{
GENERATED_BODY()
public:
virtual void Initialize(FSubsystemCollectionBase& Collection) override;
virtual void Deinitialize() override;
/// Event raised when a global variable is changed. "FromScript" is true if the variable was set by the script, false if set from code
UPROPERTY(BlueprintAssignable)
FOnGlobalVariableChangedEvent OnGlobalVariableChanged;
protected:
UPROPERTY()
TObjectPtr<USoundConcurrency> VoiceConcurrency;
/// Global variable state
TMap<FName, FSUDSValue> GlobalVariableState;
void SetGlobalVariableImpl(FName Name, const FSUDSValue& Value, bool bFromScript, int LineNo)
{
const FSUDSValue OldValue = GetGlobalVariable(Name);
if (!IsGlobalVariableSet(Name) ||
(OldValue != Value).GetBooleanValue())
{
GlobalVariableState.Add(Name, Value);
OnGlobalVariableChanged.Broadcast(Name, Value, bFromScript);
}
}
public:
/**
* Sets the number of voiced lines that can be played at once. Defaults to 1, so that when a new voiced line is
* played while another is still playing, the previous one is stopped. If you would prefer that they overlap, set
* this to >1
* @param ConcurrentLines The number of voice lines that can be played at once.
*/
UFUNCTION(BlueprintCallable, Category="SUDS|Settings")
void SetMaxConcurrentVoicedLines(int ConcurrentLines);
/**
* Gets the number of voiced lines that can be played at once. Defaults to 1, so that when a new voiced line is
* played while another is still playing, the previous one is stopped.
*/
UFUNCTION(BlueprintCallable, Category="SUDS|Settings")
int GetMaxConcurrentVoicedLines() const;
USoundConcurrency* GetVoicedLineConcurrency() const { return VoiceConcurrency; }
/**
* Reset the global state of the system.
* @param bResetVariables If true, resets all variable state
*/
UFUNCTION(BlueprintCallable, Category="SUDS|Global State")
void ResetGlobalState(bool bResetVariables = true);
/** Retrieve a copy of the global state of the system.
* This is useful for saving the state of e.g. global variables.
* @return A static copy of the current global state. This struct can be serialised with your save data,
* and contains the state of global variables.
*/
UFUNCTION(BlueprintCallable, Category="SUDS|Global State")
FSUDSGlobalState GetSavedGlobalState() const;
/** Restore the global saved state.
* This is useful for restoring global state eg global variables when you're loading a game.
* @param State Global state that you previously retrieved from GetSavedGlobalState().
*/
UFUNCTION(BlueprintCallable, Category="SUDS|Dialogue")
void RestoreSavedGlobalState(const FSUDSGlobalState& State);
/// Set a global variable
/// This is mostly only useful if you happen to already have a general purpose FSUDSValue.
/// See SetGlobalVariableText, SetGlobalVariableInt etc for literal-friendly versions
UFUNCTION(BlueprintCallable, Category="SUDS|Global Variables")
void SetGlobalVariable(FName Name, FSUDSValue Value)
{
SetGlobalVariableImpl(Name, Value, false, 0);
}
/// Internal use only
void InternalSetGlobalVariable(FName Name, const FSUDSValue& Value, bool bFromScript, int LineNo) { SetGlobalVariableImpl(Name, Value, bFromScript, LineNo); }
/// Get a variable in dialogue state as a general value type
/// See GetDialogueText, GetDialogueInt etc for more type friendly versions, but if you want to access the state
/// as a type-flexible value then you can do so with this function.
UFUNCTION(BlueprintCallable, Category="SUDS|Global Variables")
FSUDSValue GetGlobalVariable(FName Name) const
{
if (const auto Arg = GlobalVariableState.Find(Name))
{
return *Arg;
}
return FSUDSValue();
}
UFUNCTION(BlueprintCallable, Category="SUDS|Global Variables")
bool IsGlobalVariableSet(FName Name) const
{
return GlobalVariableState.Contains(Name);
}
/// Get all variables
UFUNCTION(BlueprintCallable, Category="SUDS|Global Variables")
const TMap<FName, FSUDSValue>& GetGlobalVariables() const { return GlobalVariableState; }
/**
* Set a text global variable
* @param Name The name of the variable
* @param Value The value of the variable
*/
UFUNCTION(BlueprintCallable, Category="SUDS|Global Variables")
void SetGlobalVariableText(FName Name, FText Value)
{
SetGlobalVariable(Name, Value);
}
/**
* Get a text global variable
* @param Name The name of the variable
* @returns Value The value of the variable
*/
UFUNCTION(BlueprintCallable, Category="SUDS|Global Variables")
FText GetGlobalVariableText(FName Name) const;
/**
* Set a global variable on the passed in parameters collection.
* @param Name The name of the variable
* @param Value The value of the variable
*/
UFUNCTION(BlueprintCallable, Category="SUDS|Global Variables")
void SetGlobalVariableInt(FName Name, int32 Value);
/**
* Get an int global variable
* @param Name The name of the variable
* @returns Value The value of the variable
*/
UFUNCTION(BlueprintCallable, Category="SUDS|Global Variables")
int GetGlobalVariableInt(FName Name) const;
/**
* Set a float global variable
* @param Name The name of the variable
* @param Value The value of the variable
*/
UFUNCTION(BlueprintCallable, Category="SUDS|Global Variables")
void SetGlobalVariableFloat(FName Name, float Value);
/**
* Get a float global variable
* @param Name The name of the variable
* @returns Value The value of the variable
*/
UFUNCTION(BlueprintCallable, Category="SUDS|Global Variables")
float GetGlobalVariableFloat(FName Name) const;
/**
* Set a gender global variable
* @param Name The name of the variable
* @param Value The value of the variable
*/
UFUNCTION(BlueprintCallable, Category="SUDS|Global Variables")
void SetGlobalVariableGender(FName Name, ETextGender Value);
/**
* Get a gender global variable
* @param Name The name of the variable
* @returns Value The value of the variable
*/
UFUNCTION(BlueprintCallable, Category="SUDS|Global Variables")
ETextGender GetGlobalVariableGender(FName Name) const;
/**
* Set a boolean global variable
* @param Name The name of the variable
* @param Value The value of the variable
*/
UFUNCTION(BlueprintCallable, Category="SUDS|Global Variables")
void SetGlobalVariableBoolean(FName Name, bool Value);
/**
* Get a boolean global variable
* @param Name The name of the variable
* @returns Value The value of the variable
*/
UFUNCTION(BlueprintCallable, Category="SUDS|Global Variables")
bool GetGlobalVariableBoolean(FName Name) const;
/**
* Set a name global variable
* @param Name The name of the variable
* @param Value The value of the variable
*/
UFUNCTION(BlueprintCallable, Category="SUDS|Global Variables")
void SetGlobalVariableName(FName Name, FName Value);
/**
* Get a name global variable
* @param Name The name of the variable
* @returns Value The value of the variable
*/
UFUNCTION(BlueprintCallable, Category="SUDS|Global Variables")
FName GetGlobalVariableName(FName Name) const;
/**
* Remove the definition of a global variable.
* This has much same effect as setting the variable back to the default value for this type, since attempting to
* retrieve a missing variable result in a default value.
* @param Name The name of the variable
*/
UFUNCTION(BlueprintCallable, Category="SUDS|Global Variables")
void UnSetGlobalVariable(FName Name);
#if WITH_EDITORONLY_DATA
/// Only for use by tests / editor tools when real subsystem isn't running
static TMap<FName, FSUDSValue> Test_DummyGlobalVariables;
#endif
};
inline USUDSSubsystem* GetSUDSSubsystem(UWorld* WorldContext)
{
if (IsValid(WorldContext) && WorldContext->IsGameWorld())
{
auto GI = WorldContext->GetGameInstance();
if (IsValid(GI))
return GI->GetSubsystem<USUDSSubsystem>();
}
return nullptr;
}

View File

@@ -0,0 +1,416 @@
// Copyright Steve Streeting 2022
// Released under the MIT license https://opensource.org/license/MIT/
#pragma once
#include "SUDSCommon.h"
#include "SUDSValue.generated.h"
UENUM(BlueprintType)
enum class ESUDSValueType : uint8
{
Text = 0,
Int = 1,
Float = 2,
Boolean = 3,
Gender = 4,
Name = 5,
/// Access the value of another variable
Variable = 10,
Empty = 99
};
/// Struct which can hold any of the value types that SUDS needs to use, in a Blueprint friendly manner
/// For getting / setting these values from blueprints, see blueprint library functions SetSUDSValue<Type>() / GetSUDSValue<Type>()
/// For convenience these are wrapped in USUDSDialogue but in e.g. event callbacks they're not
USTRUCT(BlueprintType)
struct SUDS_API FSUDSValue
{
GENERATED_BODY()
protected:
ESUDSValueType Type;
union
{
int32 IntValue;
float FloatValue;
};
TOptional<FText> TextValue;
// Used for variables and name values
TOptional<FName> Name;
public:
FSUDSValue() : Type(ESUDSValueType::Empty), IntValue(0), TextValue(FText::GetEmpty()) {}
FSUDSValue(const int32 Value)
: Type(ESUDSValueType::Int) { IntValue = Value; }
FSUDSValue(const float Value)
: Type(ESUDSValueType::Float) { FloatValue = Value; }
FSUDSValue(const FText& Value)
: Type(ESUDSValueType::Text),
IntValue(0),
TextValue(Value)
{
}
FSUDSValue(FText&& Value)
: Type(ESUDSValueType::Text), IntValue(0), TextValue(MoveTemp(Value))
{
}
FSUDSValue(ETextGender Value)
: Type(ESUDSValueType::Gender), IntValue(0)
{
IntValue = static_cast<int32>(Value);
}
FSUDSValue(bool Value)
: Type(ESUDSValueType::Boolean), IntValue(0)
{
IntValue = Value ? 1 : 0;
}
FSUDSValue(const FName& ReferencedName, bool bIsVariable)
: Type(bIsVariable ? ESUDSValueType::Variable : ESUDSValueType::Name),
IntValue(0),
Name(ReferencedName)
{
}
// Construct a default value of a given type
explicit FSUDSValue(ESUDSValueType ValType)
: Type(ValType), IntValue(0)
{
}
/// Whether this value is empty, i.e. hasn't been set to anything
FORCEINLINE bool IsEmpty() const
{
return Type == ESUDSValueType::Empty;
}
FORCEINLINE ESUDSValueType GetType() const
{
return Type;
}
FORCEINLINE int32 GetIntValue() const
{
// We don't warn for unset variables / uninitialised values, use the defaults
if (!IsEmpty() && Type != ESUDSValueType::Int && Type != ESUDSValueType::Variable)
UE_LOG(LogSUDS, Warning, TEXT("Getting value as int but was type %s"), *StaticEnum<ESUDSValueType>()->GetValueAsString(Type))
return IntValue;
}
FORCEINLINE float GetFloatValue() const
{
// We don't warn for unset variables, use the defaults
if (!IsEmpty() && !IsNumeric() && Type != ESUDSValueType::Variable)
UE_LOG(LogSUDS, Warning, TEXT("Getting value as float but was type %s"), *StaticEnum<ESUDSValueType>()->GetValueAsString(Type))
if (Type == ESUDSValueType::Int)
{
// Allow int widening to float
return GetIntValue();
}
return FloatValue;
}
FORCEINLINE const FText& GetTextValue() const
{
// We don't warn for unset variables, use the defaults
if (!IsEmpty() && Type != ESUDSValueType::Text && Type != ESUDSValueType::Variable)
UE_LOG(LogSUDS, Warning, TEXT("Getting value as text but was type %s"), *StaticEnum<ESUDSValueType>()->GetValueAsString(Type))
if (TextValue.IsSet())
return TextValue.GetValue();
return FText::GetEmpty();
}
FORCEINLINE ETextGender GetGenderValue() const
{
// We don't warn for unset variables, use the defaults
if (!IsEmpty() && Type != ESUDSValueType::Gender && Type != ESUDSValueType::Variable)
UE_LOG(LogSUDS, Warning, TEXT("Getting value as float but was type %s"), *StaticEnum<ESUDSValueType>()->GetValueAsString(Type))
return static_cast<ETextGender>(IntValue);
}
FORCEINLINE bool GetBooleanValue() const
{
// We don't warn for unset variables, use the defaults
if (!IsEmpty() && Type != ESUDSValueType::Boolean && Type != ESUDSValueType::Variable)
UE_LOG(LogSUDS, Warning, TEXT("Getting value as boolean but was type %s"), *StaticEnum<ESUDSValueType>()->GetValueAsString(Type))
return IntValue != 0;
}
FORCEINLINE FName GetNameValue() const
{
// We don't warn for unset variables, use the defaults
if (!IsEmpty() && Type != ESUDSValueType::Name && Type != ESUDSValueType::Variable)
UE_LOG(LogSUDS, Warning, TEXT("Getting value as Name but was type %s"), *StaticEnum<ESUDSValueType>()->GetValueAsString(Type))
if (Name.IsSet())
return Name.GetValue();
return NAME_None;
}
FORCEINLINE FName GetVariableNameValue() const
{
if (!IsEmpty() && Type != ESUDSValueType::Variable)
UE_LOG(LogSUDS, Warning, TEXT("Getting value as variable name but was type %s"), *StaticEnum<ESUDSValueType>()->GetValueAsString(Type))
if (Name.IsSet())
return Name.GetValue();
return NAME_None;
}
FORCEINLINE bool IsVariable() const
{
return Type == ESUDSValueType::Variable;
}
bool IsNumeric() const
{
return Type == ESUDSValueType::Float || Type == ESUDSValueType::Int;
}
FFormatArgumentValue ToFormatArg() const
{
switch (Type)
{
default:
case ESUDSValueType::Text:
return FFormatArgumentValue(TextValue.GetValue());
case ESUDSValueType::Int:
return FFormatArgumentValue(GetIntValue());
case ESUDSValueType::Boolean:
return FFormatArgumentValue(GetBooleanValue());
case ESUDSValueType::Gender:
return FFormatArgumentValue(GetGenderValue());
case ESUDSValueType::Float:
return FFormatArgumentValue(GetFloatValue());
case ESUDSValueType::Name:
// return something useful from FName even though technically shouldn't use in visible text
return FFormatArgumentValue(FText::FromName(GetNameValue()));
case ESUDSValueType::Empty:
case ESUDSValueType::Variable:
return FFormatArgumentValue();
}
}
void SetValue(const FSUDSValue& RValue)
{
*this = RValue;
}
/// Not operation, technically only valid on booleans, but not enforced so as to allow unset vars & conversions
FSUDSValue operator!() const
{
return FSUDSValue(!GetBooleanValue());
}
FSUDSValue operator*(const FSUDSValue& Rhs) const
{
// We don't force types to be numeric here so that we can safely use unset variables
// If both int, keep int
if (Type == ESUDSValueType::Int && Rhs.Type == Type)
{
return FSUDSValue(GetIntValue() * Rhs.GetIntValue());
}
// Otherwise we'll let the type widening handle mixed types for us (ternary will always resolve to float)
return FSUDSValue(
(Type == ESUDSValueType::Float ? GetFloatValue() : GetIntValue())
*
(Rhs.GetType() == ESUDSValueType::Float ? Rhs.GetFloatValue() : Rhs.GetIntValue()));
}
FSUDSValue operator/(const FSUDSValue& Rhs) const
{
// We don't force types to be numeric here so that we can safely use unset variables
// If both int, keep int
if (Type == ESUDSValueType::Int && Rhs.Type == Type)
{
return FSUDSValue(GetIntValue() / Rhs.GetIntValue());
}
// Otherwise we'll let the type widening handle mixed types for us (ternary will always resolve to float)
return FSUDSValue(
(Type == ESUDSValueType::Float ? GetFloatValue() : GetIntValue())
/
(Rhs.GetType() == ESUDSValueType::Float ? Rhs.GetFloatValue() : Rhs.GetIntValue()));
}
FSUDSValue operator%(const FSUDSValue& Rhs) const
{
// We don't force types to be numeric here so that we can safely use unset variables
// If both int, keep int
if (Type == ESUDSValueType::Int && Rhs.Type == Type)
{
return FSUDSValue(GetIntValue() % Rhs.GetIntValue());
}
// Otherwise we'll let the type widening handle mixed types for us (ternary will always resolve to float)
float lval = (Type == ESUDSValueType::Float ? GetFloatValue() : GetIntValue());
float rval = (Rhs.GetType() == ESUDSValueType::Float ? Rhs.GetFloatValue() : Rhs.GetIntValue());
// Prevent NaN and errors reported when executing FMath::Fmod with invalid values.
return FSUDSValue(rval != 0 ? FMath::Fmod(lval, rval) : 0.0f);
}
FSUDSValue operator+(const FSUDSValue& Rhs) const
{
// We don't force types to be numeric here so that we can safely use unset variables
// If both int, keep int
if (Type == ESUDSValueType::Int && Rhs.Type == Type)
{
return FSUDSValue(GetIntValue() + Rhs.GetIntValue());
}
// Otherwise we'll let the type widening handle mixed types for us (ternary will always resolve to float)
return FSUDSValue(
(Type == ESUDSValueType::Float ? GetFloatValue() : GetIntValue())
+
(Rhs.GetType() == ESUDSValueType::Float ? Rhs.GetFloatValue() : Rhs.GetIntValue()));
}
FSUDSValue operator-(const FSUDSValue& Rhs) const
{
// We don't force types to be numeric here so that we can safely use unset variables
// If both int, keep int
if (Type == ESUDSValueType::Int && Rhs.Type == Type)
{
return FSUDSValue(GetIntValue() - Rhs.GetIntValue());
}
// Otherwise we'll let the type widening handle mixed types for us (ternary will always resolve to float)
return FSUDSValue(
(Type == ESUDSValueType::Float ? GetFloatValue() : GetIntValue())
-
(Rhs.GetType() == ESUDSValueType::Float ? Rhs.GetFloatValue() : Rhs.GetIntValue()));
}
FSUDSValue operator<(const FSUDSValue& Rhs) const
{
// Don't check types here. We'll fall back on int comparisons which is important for cases where
// a variable hasn't been set
// result is boolean so no need to protect types
return FSUDSValue(
(Type == ESUDSValueType::Float ? GetFloatValue() : GetIntValue())
<
(Rhs.GetType() == ESUDSValueType::Float ? Rhs.GetFloatValue() : Rhs.GetIntValue()));
}
FSUDSValue operator==(const FSUDSValue& Rhs) const
{
if (IsNumeric() || Rhs.IsNumeric())
{
if (GetType() == ESUDSValueType::Float || Rhs.GetType() == ESUDSValueType::Float)
{
// For floats, use tolerance
return FSUDSValue(FMath::IsNearlyEqual(
GetType() == ESUDSValueType::Int ? (float)GetIntValue() : GetFloatValue(),
Rhs.GetType() == ESUDSValueType::Int ? (float)Rhs.GetIntValue() : Rhs.GetFloatValue()));
}
else
{
return FSUDSValue(GetIntValue() == Rhs.GetIntValue());
}
}
else
{
// no auto conversion here
// however, tolerate unresolved variables, they will return initial values
if (Type != Rhs.Type && Type != ESUDSValueType::Variable && Rhs.Type != ESUDSValueType::Variable)
{
return false;
}
// Compare using type from whichever one isn't a variable (get values on unset variables will return defaults)
ESUDSValueType UseType = IsVariable() ? Rhs.GetType() : GetType();
switch (UseType)
{
case ESUDSValueType::Text:
return FSUDSValue(GetTextValue().EqualTo(Rhs.GetTextValue()));
case ESUDSValueType::Boolean:
return FSUDSValue(GetBooleanValue() == Rhs.GetBooleanValue());
case ESUDSValueType::Gender:
return FSUDSValue(GetGenderValue() == Rhs.GetGenderValue());
case ESUDSValueType::Variable:
return FSUDSValue(GetVariableNameValue() == Rhs.GetVariableNameValue());
case ESUDSValueType::Name:
return FSUDSValue(GetNameValue() == Rhs.GetNameValue());
// deal with int/float again here, this mops up cases where one side is an unset variable
case ESUDSValueType::Int:
return FSUDSValue(GetIntValue() == Rhs.GetIntValue());
case ESUDSValueType::Float:
return FSUDSValue(GetFloatValue() == Rhs.GetFloatValue());
default:
break;
};
}
return FSUDSValue(false);
}
FSUDSValue operator<=(const FSUDSValue& Rhs) const
{
if ((*this < Rhs).GetBooleanValue())
return FSUDSValue(true);
return (*this == Rhs);
}
FSUDSValue operator>(const FSUDSValue& Rhs) const
{
return Rhs < *this;
}
FSUDSValue operator>=(const FSUDSValue& Rhs) const
{
return Rhs <= *this;
}
FSUDSValue operator!=(const FSUDSValue& Rhs) const
{
return !(*this == Rhs);
}
FSUDSValue operator&&(const FSUDSValue& Rhs) const
{
// We always let unset variables degrade to false
check(Type == ESUDSValueType::Boolean || Type == ESUDSValueType::Variable);
check(Rhs.Type == ESUDSValueType::Boolean || Rhs.Type == ESUDSValueType::Variable);
return FSUDSValue(GetBooleanValue() && Rhs.GetBooleanValue());
}
FSUDSValue operator||(const FSUDSValue& Rhs) const
{
// We always let unset variables degrade to false
check(Type == ESUDSValueType::Boolean || Type == ESUDSValueType::Variable);
check(Rhs.Type == ESUDSValueType::Boolean || Rhs.Type == ESUDSValueType::Variable);
return FSUDSValue(GetBooleanValue() || Rhs.GetBooleanValue());
}
SUDS_API friend FArchive& operator<<(FArchive& Ar, FSUDSValue& Value);
SUDS_API friend void operator<<(FStructuredArchive::FSlot Slot, FSUDSValue& Value);
bool Serialize(FArchive& Ar)
{
Ar << *this;
return true;
}
bool Serialize(FStructuredArchive::FSlot Slot)
{
Slot << *this;
return true;
}
FString ToString() const;
bool ExportTextItem(FString& ValueStr, FSUDSValue const& DefaultValue, UObject* Parent, int32 PortFlags, UObject* ExportRootScope) const;
};
template<>
struct TStructOpsTypeTraits<FSUDSValue> : public TStructOpsTypeTraitsBase2<FSUDSValue>
{
enum
{
WithSerializer = true,
WithExportTextItem = true
};
};

View File

@@ -0,0 +1,53 @@
// Copyright Steve Streeting 2022
// Released under the MIT license https://opensource.org/license/MIT/
using UnrealBuildTool;
public class SUDS : ModuleRules
{
public SUDS(ReadOnlyTargetRules Target) : base(Target)
{
PCHUsage = ModuleRules.PCHUsageMode.UseExplicitOrSharedPCHs;
PublicIncludePaths.AddRange(
new string[] {
// ... add public include paths required here ...
}
);
PrivateIncludePaths.AddRange(
new string[] {
// ... add other private include paths required here ...
}
);
PublicDependencyModuleNames.AddRange(
new string[]
{
"Core",
// ... add other public dependencies that you statically link with here ...
}
);
PrivateDependencyModuleNames.AddRange(
new string[]
{
"CoreUObject",
"Engine",
"Slate",
"SlateCore",
// ... add private dependencies that you statically link with here ...
}
);
DynamicallyLoadedModuleNames.AddRange(
new string[]
{
// ... add any modules that your module loads dynamically here ...
}
);
}
}

View File

@@ -0,0 +1,65 @@
// Copyright Steve Streeting 2022
// Released under the MIT license https://opensource.org/license/MIT/
#include "SUDSEditor.h"
#include "ISettingsModule.h"
#include "ISettingsSection.h"
#include "SUDSEditorSettings.h"
#include "SUDSScriptActions.h"
#include "Interfaces/IPluginManager.h"
#include "Styling/SlateStyle.h"
#include "Styling/SlateStyleRegistry.h"
#define LOCTEXT_NAMESPACE "FSUDSModule"
void FSUDSEditorModule::StartupModule()
{
ScriptActions = MakeShared<FSUDSScriptActions>();
FAssetToolsModule::GetModule().Get().RegisterAssetTypeActions(ScriptActions.ToSharedRef());
auto SudsPlugin = IPluginManager::Get().FindPlugin(TEXT("SUDS"));
if (SudsPlugin.IsValid())
{
const FString IconDir = SudsPlugin->GetBaseDir() + "/Content/Editor/Slate/Icons";
const FVector2D Sz16 = FVector2D(16.0f, 16.0f);
const FVector2D Sz64 = FVector2D(64.0f, 64.0f);
StyleSet = MakeShared<FSlateStyleSet>("SUDSStyleSet");
StyleSet->Set(TEXT("ClassIcon.SUDSScript"), new FSlateImageBrush(IconDir / TEXT("SUDSScript_16x.png"), Sz16));
StyleSet->Set(TEXT("ClassThumbnail.SUDSScript"), new FSlateImageBrush(IconDir / TEXT("SUDSScript_64x.png"), Sz64));
FSlateStyleRegistry::RegisterSlateStyle(*StyleSet.Get());
}
// register settings
ISettingsModule* SettingsModule = FModuleManager::GetModulePtr<ISettingsModule>("Settings");
if (SettingsModule)
{
ISettingsSectionPtr SettingsSection = SettingsModule->RegisterSettings("Project", "Plugins", "SUDS Editor",
LOCTEXT("SUDSEditorSettingsName", "SUDS Editor"),
LOCTEXT("SUDSEditorSettingsDescription", "Configure the editor parts of SUDS."),
GetMutableDefault<USUDSEditorSettings>()
);
}
UE_LOG(LogSUDSEditor, Log, TEXT("SUDS Editor Module Started"))
}
void FSUDSEditorModule::ShutdownModule()
{
if (StyleSet.IsValid())
{
FSlateStyleRegistry::UnRegisterSlateStyle(*StyleSet.Get());
}
if (!FModuleManager::Get().IsModuleLoaded("AssetTools")) return;
FAssetToolsModule::GetModule().Get().UnregisterAssetTypeActions(ScriptActions.ToSharedRef());
UE_LOG(LogSUDSEditor, Log, TEXT("SUDS Editor Module Shut Down"))
}
#undef LOCTEXT_NAMESPACE
IMPLEMENT_MODULE(FSUDSEditorModule, SUDSEditor)
DEFINE_LOG_CATEGORY(LogSUDSEditor);

View File

@@ -0,0 +1,280 @@
// Copyright Steve Streeting 2022
// Released under the MIT license https://opensource.org/license/MIT/
#include "SUDSEditorScriptTools.h"
#include "SUDSMessageLogger.h"
#include "SUDSScript.h"
#include "SUDSScriptImporter.h"
#include "SUDSScriptNode.h"
#include "SUDSScriptNodeGosub.h"
#include "SUDSScriptNodeSet.h"
#include "SUDSScriptNodeText.h"
#include "EditorFramework/AssetImportData.h"
#include "Misc/FileHelper.h"
#include "Misc/PackageName.h"
#include "Misc/Paths.h"
#include "UObject/Package.h"
void FSUDSEditorScriptTools::WriteBackTextIDs(USUDSScript* Script, FSUDSMessageLogger& Logger)
{
const auto& SrcData = Script->AssetImportData->SourceData;
if (SrcData.SourceFiles.Num() == 1)
{
TArray<FString> Lines;
FString SourceFile = SrcData.SourceFiles[0].RelativeFilename;
auto Package = Script->GetPackage();
if (FPaths::IsRelative(SourceFile) && Package)
{
FString PackagePath = FPackageName::LongPackageNameToFilename(FPackageName::GetLongPackagePath(Package->GetPathName()));
SourceFile = FPaths::ConvertRelativePathToFull(PackagePath, SourceFile);
}
if (FFileHelper::LoadFileToStringArray(Lines, *SourceFile))
{
const int PrevErrs = Logger.NumErrors();
const bool bHeaderChanges = WriteBackTextIDsFromNodes(Script->GetHeaderNodes(), Lines, Script->GetName(), Logger);
const bool bBodyChanges = WriteBackTextIDsFromNodes(Script->GetNodes(), Lines, Script->GetName(), Logger);
if (Logger.NumErrors() > PrevErrs)
{
Logger.AddMessage(EMessageSeverity::Info,
FText::FromString(FString::Printf(TEXT("Errors prevented saving updates to %s."), *Script->GetName())));
}
else if (bHeaderChanges || bBodyChanges)
{
// We need to re-hash file in memory before saving to prevent re-import
int32 Length = 10;
for(const FString& Line : Lines)
{
Length += Line.Len() + UE_ARRAY_COUNT(LINE_TERMINATOR);
}
FString CombinedString;
CombinedString.Reserve(Length);
for(const FString& Line : Lines)
{
CombinedString += Line;
CombinedString += LINE_TERMINATOR;
}
// Write source file back; always encode as UTF8 not default ANSI/UTF16
// Do this before updating asset import data so it picks up new file timestamp
FFileHelper::SaveStringToFile(FStringView(CombinedString), *SourceFile, FFileHelper::EEncodingOptions::ForceUTF8);
/* BEGIN try to prevent re-import prompt
* This unfortunately didn't work, so removed & let it reimport
const FMD5Hash FileHash = FSUDSScriptImporter::CalculateHash(*CombinedString, CombinedString.Len());
Script->AssetImportData->Update(SourceFile, FileHash);
// Need to mark asset dirty since hash has changed
// Try to find the outer package so we can dirty it up
if (Script->GetOuter())
{
Script->GetOuter()->MarkPackageDirty();
}
else
{
Script->MarkPackageDirty();
}
// Even this doesn't work:
GUnrealEd->AutoReimportManager->IgnoreFileModification(SourceFile);
* END try to prevent re-import
*/
Logger.AddMessage(EMessageSeverity::Info,
FText::FromString(FString::Printf(TEXT("Successfully updated %s"), *SourceFile)));
}
else
{
Logger.AddMessage(EMessageSeverity::Info,
FText::FromString(FString::Printf(TEXT("No changes were required to %s"), *SourceFile)));
}
}
else
{
Logger.AddMessage(EMessageSeverity::Error,
FText::FromString(FString::Printf(TEXT("Error opening source asset %s"), *SourceFile)));
}
}
else
{
Logger.AddMessage(EMessageSeverity::Error,
FText::FromString(
FString::Printf(TEXT("No source files associated with asset %s"), *Script->GetName())));
}
}
bool FSUDSEditorScriptTools::WriteBackTextIDsFromNodes(const TArray<USUDSScriptNode*> Nodes, TArray<FString>& Lines, const FString& NameForErrors, FSUDSMessageLogger& Logger)
{
bool bAnyChanges = false;
// For each speaker line, set line and choice edge, use source line no to append text ID
for (const auto& N : Nodes)
{
if (N->GetNodeType() == ESUDSScriptNodeType::Text)
{
if (const auto* TN = Cast<USUDSScriptNodeText>(N))
{
bAnyChanges = WriteBackTextID(TN->GetText(), TN->GetSourceLineNo(), Lines, NameForErrors, Logger) || bAnyChanges;
}
}
else if (N->GetNodeType() == ESUDSScriptNodeType::SetVariable)
{
if (const auto* SN = Cast<USUDSScriptNodeSet>(N))
{
if (SN->GetExpression().IsTextLiteral())
{
FText Literal = SN->GetExpression().GetTextLiteralValue();
bAnyChanges = WriteBackTextID(Literal, SN->GetSourceLineNo(), Lines, NameForErrors, Logger) || bAnyChanges;
}
}
}
else if (N->GetNodeType() == ESUDSScriptNodeType::Choice)
{
// Edges
for (auto& Edge : N->GetEdges())
{
bAnyChanges = WriteBackTextID(Edge.GetText(), Edge.GetSourceLineNo(), Lines, NameForErrors, Logger) || bAnyChanges;
}
}
else if (N->GetNodeType() == ESUDSScriptNodeType::Gosub)
{
if (const auto* GN = Cast<USUDSScriptNodeGosub>(N))
// Need to write back Gosub IDs so that saved return stacks work
bAnyChanges = WriteBackGosubID(GN->GetGosubID(), GN->GetSourceLineNo(), Lines, NameForErrors, Logger) || bAnyChanges;
}
}
return bAnyChanges;
}
bool FSUDSEditorScriptTools::WriteBackTextID(const FText& AssetText, int LineNo, TArray<FString>& Lines, const FString& NameForErrors, FSUDSMessageLogger& Logger)
{
if (AssetText.IsEmpty())
return false;
// Line numbers are 1-based
const int Idx = LineNo - 1;
if (!Lines.IsValidIndex(Idx))
{
Logger.AddMessage(EMessageSeverity::Error,
FText::FromString(FString::Printf(
TEXT("Cannot write back TextID to '%s', source line number %d is invalid (Text: '%s')"),
*NameForErrors,
LineNo,
*AssetText.ToString())));
return false;
}
const FString& SourceLine = Lines[Idx];
const FString TextID = SUDS_GET_TEXT_KEY(AssetText);
FString ExistingTextID;
int ExistingNum;
FStringView SourceLineView(SourceLine);
const bool bFoundExisting = FSUDSScriptImporter::RetrieveTextIDFromLine(SourceLineView, ExistingTextID, ExistingNum);
// Existing TextID - replace if already there
if (!bFoundExisting || ExistingTextID != TextID)
{
if (TextIDCheckMatch(AssetText, SourceLine))
{
FString Prefix(SourceLineView);
FString UpdatedLine = FString::Printf(TEXT("%s %s"), *Prefix, *TextID);
Lines[Idx] = UpdatedLine;
return true;
}
else
{
Logger.AddMessage(EMessageSeverity::Error,
FText::FromString(FString::Printf(TEXT(
"Tried to set TextID on line %d of %s but source file did not contain expected text '%s'"
),
LineNo,
*NameForErrors,
*AssetText.ToString())));
return false;
}
}
// Same, no need to change
return false;
}
bool FSUDSEditorScriptTools::TextIDCheckMatch(const FText& AssetText, const FString& SourceLine)
{
// Text from our asset must be present in the text in the source line
// However, in case this is a multi-line string, take the first line only
FString AssetStr = AssetText.ToString();
{
FString L, R;
if (AssetStr.Split("\n", &L, &R))
{
AssetStr = L.TrimStartAndEnd();
}
}
// Bear in mind that this line might be a text line, a choice or a set line
// therefore we only check if the source line contains the asset text
return SourceLine.Contains(AssetStr);
}
bool FSUDSEditorScriptTools::WriteBackGosubID(const FString& GosubID,
int LineNo,
TArray<FString>& Lines,
const FString& NameForErrors,
FSUDSMessageLogger& Logger)
{
// Line numbers are 1-based
const int Idx = LineNo - 1;
if (!Lines.IsValidIndex(Idx))
{
Logger.AddMessage(EMessageSeverity::Error,
FText::FromString(FString::Printf(
TEXT("Cannot write back GosubID to '%s', source line number %d is invalid"),
*NameForErrors,
LineNo)));
return false;
}
const FString& SourceLine = Lines[Idx];
FString ExistingID;
int ExistingNum;
FStringView SourceLineView(SourceLine);
const bool bFoundID = FSUDSScriptImporter::RetrieveGosubIDFromLine(SourceLineView, ExistingID, ExistingNum);
if (!bFoundID || ExistingID != GosubID)
{
// Check it's a gosub line
if (SourceLine.Contains(TEXT("gosub")) || SourceLine.Contains(TEXT("go sub")))
{
FString Prefix(SourceLineView);
FString UpdatedLine = FString::Printf(TEXT("%s %s"), *Prefix, *GosubID);
Lines[Idx] = UpdatedLine;
return true;
}
else
{
Logger.AddMessage(EMessageSeverity::Error,
FText::FromString(FString::Printf(TEXT(
"Tried to set GosubID on line %d of %s but source file did not have a gosubu on that line"
),
LineNo,
*NameForErrors)));
return false;
}
}
// Same, no need to change
return false;
}

View File

@@ -0,0 +1,48 @@
// Copyright Steve Streeting 2022
// Released under the MIT license https://opensource.org/license/MIT/
#include "SUDSEditorSettings.h"
#include "Misc/Paths.h"
bool USUDSEditorSettings::ShouldGenerateVoiceAssets(const FString& PackagePath) const
{
if (AlwaysAutoGenerateVoiceOverAssetsOnImport)
return true;
for (auto Dir : DirectoriesToAutoGenerateVoiceOverAssetsOnImport)
{
if (FPaths::IsUnderDirectory(PackagePath, Dir.Path))
{
return true;
}
}
return false;
}
FString USUDSEditorSettings::GetVoiceOutputDir(const FString& PackagePath, const FString& ScriptName) const
{
return GetOutputDir(DialogueVoiceAssetLocation, DialogueVoiceAssetSharedDir.Path, PackagePath, ScriptName);
}
FString USUDSEditorSettings::GetWaveOutputDir(const FString& PackagePath, const FString& ScriptName) const
{
return GetOutputDir(DialogueWaveAssetLocation, DialogueWaveAssetSharedDir.Path, PackagePath, ScriptName);
}
FString USUDSEditorSettings::GetOutputDir(ESUDSAssetLocation Location,
const FString& SharedPath,
const FString& PackagePath,
const FString& ScriptName)
{
switch(Location)
{
default:
case ESUDSAssetLocation::SharedDirectory:
return SharedPath;
case ESUDSAssetLocation::SharedDirectorySubdir:
return FPaths::Combine(SharedPath, ScriptName);
case ESUDSAssetLocation::ScriptDirectory:
return PackagePath;
case ESUDSAssetLocation::ScriptDirectorySubdir:
return FPaths::Combine(PackagePath, ScriptName);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,343 @@
// Copyright Steve Streeting 2022
// Released under the MIT license https://opensource.org/license/MIT/
#pragma once
#include "CoreMinimal.h"
#include "SUDSValue.h"
#include "Framework/Commands/Commands.h"
#include "Framework/Text/BaseTextLayoutMarshaller.h"
#include "Toolkits/AssetEditorToolkit.h"
#include "UObject/Object.h"
#include "Widgets/Views/STableRow.h"
class SMultiLineEditableTextBox;
struct FSUDSValue;
class USUDSDialogue;
class USUDSScript;
#define LOCTEXT_NAMESPACE "SUDS"
class FSUDSToolbarCommands
: public TCommands<FSUDSToolbarCommands>
{
public:
FSUDSToolbarCommands()
: TCommands<FSUDSToolbarCommands>(
"SUDS",
LOCTEXT("SUDS", "Steves Unreal Dialogue System"),
NAME_None,
#if ENGINE_MAJOR_VERSION == 5 && ENGINE_MINOR_VERSION > 0
FAppStyle::GetAppStyleSetName()
#else
FEditorStyle::GetStyleSetName()
#endif
)
{ }
public:
// TCommands interface
virtual void RegisterCommands() override
{
UI_COMMAND(StartDialogue, "Start Dialogue", "Start/restart dialogue", EUserInterfaceActionType::Button, FInputChord());
UI_COMMAND(WriteBackTextIDs, "Write String Keys", "Write string keys back to script source to stabilise for localisation / voice asset links", EUserInterfaceActionType::Button, FInputChord());
UI_COMMAND(GenerateVOAssets, "Generate Voice Assets", "Generate DialogueVoice and DialogueWave assets for VO", EUserInterfaceActionType::Button, FInputChord());
}
public:
TSharedPtr<FUICommandInfo> StartDialogue;
TSharedPtr<FUICommandInfo> WriteBackTextIDs;
TSharedPtr<FUICommandInfo> GenerateVOAssets;
};
class FSUDSEditorOutputRow
{
public:
FText Prefix;
FText Line;
FSlateColor PrefixColour;
FSlateColor LineColour;
FSlateColor BgColour;
FSUDSEditorOutputRow(const FText& InPrefix,
const FText& InLine,
const FSlateColor& InPrefixColour,
const FSlateColor& InLineColour,
const FSlateColor& InBgColour) :
Prefix(InPrefix),
Line(InLine),
PrefixColour(InPrefixColour),
LineColour(InLineColour),
BgColour(InBgColour)
{
}
};
class SSUDSEditorOutputItem : public SMultiColumnTableRow< TSharedPtr<FString> >
{
public:
SLATE_BEGIN_ARGS(SSUDSEditorOutputItem)
{}
SLATE_ARGUMENT(float, InitialWidth)
SLATE_ARGUMENT(FText, Prefix)
SLATE_ARGUMENT(FText, Line)
SLATE_ARGUMENT(FSlateColor, PrefixColour)
SLATE_ARGUMENT(FSlateColor, LineColour)
SLATE_ARGUMENT(FSlateColor, BgColour)
SLATE_END_ARGS()
public:
/**
* Construct this widget. Called by the SNew() Slate macro.
*
* @param InArgs Declaration used by the SNew() macro to construct this widget.
* @oaram InOwnerTableView The owner table into which this row is being placed.
*/
void Construct( const FArguments& InArgs, const TSharedRef<STableViewBase>& InOwnerTableView );
/**
* Generates the widget for the specified column.
*
* @param ColumnName The name of the column to generate the widget for.
* @return The widget.
*/
virtual TSharedRef<SWidget> GenerateWidgetForColumn( const FName& ColumnName ) override;
protected:
float InitialWidth = 70;
FText Prefix;
FText Line;
FSlateColor PrefixColour;
FSlateColor LineColour;
};
class FSUDSEditorVariableRow
{
public:
FName Name;
FSUDSValue Value;
bool bIsManualOverride;
FSUDSEditorVariableRow(const FName& InName, const FSUDSValue& InValue, bool bIsManual) : Name(InName),
Value(InValue),
bIsManualOverride(bIsManual)
{
}
friend bool operator<(const FSUDSEditorVariableRow& Lhs, const FSUDSEditorVariableRow& RHS)
{
return Lhs.Name.LexicalLess(RHS.Name);
}
friend bool operator<(const TSharedPtr<FSUDSEditorVariableRow>& Lhs, const TSharedPtr<FSUDSEditorVariableRow>& RHS)
{
return *Lhs < *RHS;
}
};
class SSUDSEditorVariableItem : public SMultiColumnTableRow< TSharedPtr<FString> >
{
public:
SLATE_BEGIN_ARGS(SSUDSEditorVariableItem)
{}
SLATE_ARGUMENT(float, InitialWidth)
SLATE_ARGUMENT(FName, VariableName)
SLATE_ARGUMENT(FSUDSValue, VariableValue)
SLATE_ARGUMENT(bool, bIsManualOverride)
SLATE_ARGUMENT(class FSUDSEditorToolkit*, Parent)
SLATE_END_ARGS()
public:
/**
* Construct this widget. Called by the SNew() Slate macro.
*
* @param InArgs Declaration used by the SNew() macro to construct this widget.
* @oaram InOwnerTableView The owner table into which this row is being placed.
*/
void Construct( const FArguments& InArgs, const TSharedRef<STableViewBase>& InOwnerTableView );
public:
/**
* Generates the widget for the specified column.
*
* @param ColumnName The name of the column to generate the widget for.
* @return The widget.
*/
virtual TSharedRef<SWidget> GenerateWidgetForColumn( const FName& ColumnName ) override;
protected:
float InitialWidth = 70;
FName VariableName;
FSUDSValue VariableValue;
bool bIsManualOverride = false;
class FSUDSEditorToolkit* Parent = nullptr;
TSharedRef<class SWidget> GetGenderMenu();
void OnGenderSelected(ETextGender TextGender);
virtual FVector2D ComputeDesiredSize(float) const override;
};
struct FSUDSTraceLogMessage
{
TSharedRef<FString> Message;
FName Category;
FSlateColor Colour;
FSUDSTraceLogMessage(FName InCategory, const FString& InMessage, const FSlateColor& InColour)
: Message(MakeShared<FString>(InMessage))
, Category(InCategory)
, Colour(InColour)
{
}
};
class FSUDSTraceLogMarshaller : public FBaseTextLayoutMarshaller
{
public:
FSUDSTraceLogMarshaller();
// ITextLayoutMarshaller
virtual void SetText(const FString& SourceString, FTextLayout& TargetTextLayout) override;
virtual void GetText(FString& TargetString, const FTextLayout& SourceTextLayout) override;
void AppendMessage(FName InCategory, int LineNo, const FString& Message, const FSlateColor& Colour);
void ClearMessages();
protected:
TArray< TSharedPtr<FSUDSTraceLogMessage> > Messages;
};
// Trace log widget, really just so we can tick and automatically scroll to the end
class SSUDSTraceLog : public SCompoundWidget
{
public:
SLATE_BEGIN_ARGS(SSUDSTraceLog)
{}
SLATE_END_ARGS()
SSUDSTraceLog() : bIsUserScrolled(false) {}
void Construct(const FArguments& InArgs);
virtual void Tick(const FGeometry& AllottedGeometry, const double InCurrentTime, const float InDeltaTime) override;
void AppendMessage(FName InCategory, int LineNo, const FString& Message, const FSlateColor& Colour);
void ClearMessages();
void ScrollToEnd();
protected:
bool bIsUserScrolled;
TSharedPtr<FSUDSTraceLogMarshaller> TraceLogMarshaller;
TSharedPtr<SMultiLineEditableTextBox> TraceLogTextBox;
void OnUserScrolled(float X);
};
class FSUDSEditorToolkit : public FAssetEditorToolkit
{
public:
void InitEditor(const TArray<UObject*>& InObjects);
virtual void RegisterTabSpawners(const TSharedRef<FTabManager>& InTabManager) override;
virtual void UnregisterTabSpawners(const TSharedRef<FTabManager>& InTabManager) override;
virtual FText GetBaseToolkitName() const override;
virtual FName GetToolkitFName() const override;
virtual FLinearColor GetWorldCentricTabColorScale() const override;
virtual FString GetWorldCentricTabPrefix() const override;
void UserEditVariable(const FName& Name, FSUDSValue Value);
void DeleteVariable(const FName& Name);
protected:
virtual void OnClose() override;
private:
USUDSScript* Script = nullptr;
USUDSDialogue* Dialogue = nullptr;
float VarColumnWidth = 120;
float PrefixColumnWidth = 100;
FName StartLabel = NAME_None;
bool bResetVarsOnStart = true;
FDelegateHandle ReimportDelegateHandle;
// FSUDSEditorDialogueRow needs to held by a TSharedPtr for SListView
TSharedPtr<SListView<TSharedPtr<FSUDSEditorOutputRow>>> OutputListView;
TArray<TSharedPtr<FSUDSEditorOutputRow>> OutputRows;
TSharedPtr<SVerticalBox> ChoicesBox;
TSharedPtr<SListView<TSharedPtr<FSUDSEditorVariableRow>>> VariablesListView;
TArray<TSharedPtr<FSUDSEditorVariableRow>> VariableRows;
TSharedPtr<SSUDSTraceLog> TraceLog;
TMap<FName, FSUDSValue> ManualOverrideVariables;
static FName DialogueOutputTabName;
static FName DetailsTabName;
static FName VariablesTabName;
static FName LogTabName;
const FSlateColor SpeakerColour = FLinearColor(1.0f, 1.0f, 0.6f, 1.0f);
const FSlateColor ChoiceColour = FLinearColor(0.4f, 1.0f, 0.4f, 1.0f);
const FSlateColor EventColour = FLinearColor(0.2f, 0.6f, 1.0f, 1.0f);
const FSlateColor VarSetColour = FLinearColor(0.8f, 0.6f, 0.9f, 1.0f);
const FSlateColor VarEditColour = FLinearColor(0.6f, 0.3f, 1.0f, 1.0f);
const FSlateColor StartColour = FLinearColor(1.0f, 0.5f, 0.0f, 1.0f);
const FSlateColor FinishColour = FLinearColor(1.0f, 0.3f, 0.3f, 1.0f);
const FSlateColor SelectColour = FLinearColor(1.0f, 0.0f, 0.5f, 1.0f);
const FSlateColor RowBgColour1 = FLinearColor(0.15f, 0.15f, 0.15f, 1.0f);
const FSlateColor RowBgColour2 = FLinearColor(0.3f, 0.3f, 0.3f, 1.0f);
void ExtendToolbar(FToolBarBuilder& ToolbarBuilder, TWeakPtr<SDockTab> Tab);
TSharedRef<class SWidget> GetStartLabelMenu();
FText GetSelectedStartLabel() const;
void OnStartLabelSelected(FName Label);
ECheckBoxState GetResetVarsCheckState() const;
void OnResetVarsCheckStateChanged(ECheckBoxState NewState);
FReply AddVariableClicked();
void UpdateVariables();
void EnsureTabsVisible();
void StartDialogue();
void DestroyDialogue();
void UpdateOutput();
void UpdateChoiceButtons();
void AddOutputRow(const FText& Prefix, const FText& Line, const FSlateColor& PrefixColour, const FSlateColor& LineColour);
void AddTraceLogRow(const FName& Category, int SourceLineNo, const FString& Message);
void AddDialogueStep(const FName& Category, int SourceLineNo, const FText& Description, const FText& Prefix);
void OnDialogueChoice(USUDSDialogue* Dialogue, int ChoiceIndex, int LineNo);
void OnDialogueEvent(USUDSDialogue* Dialogue, FName EventName, const TArray<FSUDSValue>& Args, int LineNo);
void OnDialogueFinished(USUDSDialogue* Dialogue);
void OnDialogueProceeding(USUDSDialogue* Dialogue);
void OnDialogueStarting(USUDSDialogue* Dialogue, FName LabelName);
void OnDialogueSpeakerLine(USUDSDialogue* Dialogue, int LineNo);
void OnDialogueSetVar(USUDSDialogue* Dialogue, FName VariableName, const FSUDSValue& ToValue, const FString& ExpressionStr, int LineNo);
void OnDialogueUserEditedVar(USUDSDialogue* Dialogue, FName VariableName, const FSUDSValue& ToValue);
void OnDialogueSelectEval(USUDSDialogue* Dialogue, const FString& ExpressionStr, bool bSuccess, int LineNo);
void OnDialogueRandomEval(USUDSDialogue* Dialogue, const int RandomOutcome, int LineNo);
TSharedRef<ITableRow> OnGenerateRowForOutput(
TSharedPtr<FSUDSEditorOutputRow> FsudsEditorDialogueRow,
const TSharedRef<STableViewBase>& TableViewBase);
TSharedRef<ITableRow> OnGenerateRowForVariable(TSharedPtr<FSUDSEditorVariableRow> Row,
const TSharedRef<STableViewBase>& Table);
FSlateColor GetColourForCategory(const FName& Category);
void OnPostReimport(UObject* Object, bool bSuccess);
void Clear();
void WriteBackTextIDs();
void GenerateVOAssets();
};
#undef LOCTEXT_NAMESPACE

View File

@@ -0,0 +1,381 @@
// Copyright Steve Streeting 2022
// Released under the MIT license https://opensource.org/license/MIT/
#include "SUDSEditorVoiceOverTools.h"
#include "ObjectTools.h"
#include "PackageTools.h"
#include "SUDSEditorSettings.h"
#include "SUDSMessageLogger.h"
#include "SUDSScript.h"
#include "SUDSScriptNodeText.h"
#include "AssetRegistry/AssetRegistryModule.h"
#include "Internationalization/Regex.h"
#include "Sound/DialogueVoice.h"
#include "Sound/DialogueWave.h"
#include "Sound/SoundWave.h"
void FSUDSEditorVoiceOverTools::GenerateAssets(USUDSScript* Script, EObjectFlags Flags, FSUDSMessageLogger* Logger)
{
// First check for problems
if (auto Settings = GetDefault<USUDSEditorSettings>())
{
bool bError = false;
if ((Settings->DialogueVoiceAssetLocation == ESUDSAssetLocation::SharedDirectory || Settings->
DialogueVoiceAssetLocation == ESUDSAssetLocation::SharedDirectorySubdir) &&
(Settings->DialogueVoiceAssetSharedDir.Path.IsEmpty() ||
!FPackageName::IsValidPath(Settings->DialogueVoiceAssetSharedDir.Path)))
{
Logger->Logf(ELogVerbosity::Error,
TEXT(
"Dialogue Voice assets are set to generate to a shared dir, but the dir '%s' is not valid."),
*Settings->DialogueVoiceAssetSharedDir.Path);
bError = true;
}
if ((Settings->DialogueWaveAssetLocation == ESUDSAssetLocation::SharedDirectory || Settings->
DialogueWaveAssetLocation == ESUDSAssetLocation::SharedDirectorySubdir) &&
(Settings->DialogueWaveAssetSharedDir.Path.IsEmpty() ||
!FPackageName::IsValidPath(Settings->DialogueWaveAssetSharedDir.Path)))
{
Logger->Logf(ELogVerbosity::Error,
TEXT(
"Dialogue Wave assets are set to generate to a shared dir, but the dir '%s' is not valid."),
*Settings->DialogueWaveAssetSharedDir.Path);
bError = true;
}
if (bError) { return; }
}
else
{
Logger->Logf(ELogVerbosity::Error, TEXT("Unable to generate voice assets, settings not found"));
return;
}
TMap<FString, UDialogueVoice*> UnsavedVoices;
GenerateVoiceAssets(Script, Flags, Logger, UnsavedVoices);
GenerateWaveAssets(Script, Flags, UnsavedVoices, Logger);
}
void FSUDSEditorVoiceOverTools::GenerateVoiceAssets(USUDSScript* Script, EObjectFlags Flags, FSUDSMessageLogger* Logger, TMap<FString, UDialogueVoice*> &OutCreatedVoices)
{
auto Registry = &FModuleManager::LoadModuleChecked<FAssetRegistryModule>(AssetRegistryConstants::ModuleName).Get();
FString Prefix;
FString ParentDir;
if (auto Settings = GetDefault<USUDSEditorSettings>())
{
Prefix = Settings->DialogueVoiceAssetPrefix;
ParentDir = GetVoiceOutputDir(Script);
}
else
{
Logger->Logf(ELogVerbosity::Error, TEXT("Unable to generate voice assets, settings not found"));
return;
}
for (auto Speaker : Script->GetSpeakers())
{
// All Dialogue Voice assets will be created in their own package
FString PackageName;
FString AssetName;
if (GetSpeakerVoiceAssetNames(Script, Speaker, PackageName, AssetName))
{
if (FPackageName::DoesPackageExist(*PackageName))
{
// Package already exists, so try and import over the top of it, if it doesn't already have a source file path
TArray<FAssetData> Assets;
if (Registry->GetAssetsByPackageName(*PackageName, Assets))
{
if (Assets.Num() > 0)
{
if (!Assets[0].GetAsset()->IsA(UDialogueVoice::StaticClass()))
{
Logger->Logf(ELogVerbosity::Error,
TEXT(
"Asset %s already exists but is not a Dialogue Voice! Cannot replace, please move aside and re-import script."),
*PackageName);
}
else
{
// Make sure we still link the existing voice
Script->SetSpeakerVoice(Speaker, Cast<UDialogueVoice>(Assets[0].GetAsset()));
Script->MarkPackageDirty();
}
// Either way nothing more to do
continue;
}
}
}
// If we got here then Dialogue Voice didn't exist (although package might have)
// It's safe to call CreatePackage either way, it'll return the existing one if needed
UPackage* Package = CreatePackage(*PackageName);
if (!ensure(Package))
{
Logger->Logf(ELogVerbosity::Error,
TEXT("Failed to create/retrieve package for voice asset %s"),
*PackageName);
}
else
{
//Logger->Logf(ELogVerbosity::Display, TEXT("Creating voice asset %s"), *PackageName);
UDialogueVoice* NewVoiceAsset = NewObject<UDialogueVoice>(Package, FName(AssetName), Flags);
OutCreatedVoices.Add(Speaker, NewVoiceAsset);
// there's nothing else to create here, voice is mostly a placeholder with the rest set up later by user
FAssetRegistryModule::AssetCreated(NewVoiceAsset);
Script->SetSpeakerVoice(Speaker, NewVoiceAsset);
Script->MarkPackageDirty();
Package->FullyLoad();
NewVoiceAsset->MarkPackageDirty();
}
}
}
}
bool FSUDSEditorVoiceOverTools::GetSpeakerVoicePackageName(USUDSScript* Script,
const FString& SpeakerID,
FString& OutPackageName)
{
FString NotUsed;
return GetSpeakerVoiceAssetNames(Script, SpeakerID, OutPackageName, NotUsed);
}
bool FSUDSEditorVoiceOverTools::GetSpeakerVoiceAssetNames(USUDSScript* Script,
const FString& SpeakerID,
FString& OutPackageName,
FString& OutAssetName)
{
if (auto Settings = GetDefault<USUDSEditorSettings>())
{
FString Prefix = Settings->DialogueVoiceAssetPrefix;
FString ParentDir = GetVoiceOutputDir(Script);
OutAssetName = FString::Printf(TEXT("%s%s"), *Prefix, *ObjectTools::SanitizeObjectName(SpeakerID));
OutPackageName = UPackageTools::SanitizePackageName(ParentDir / OutAssetName);
return true;
}
return false;
}
UDialogueVoice* FSUDSEditorVoiceOverTools::FindSpeakerVoice(USUDSScript* Script,
const FString& SpeakerID)
{
FString PackageName;
if (GetSpeakerVoicePackageName(Script, SpeakerID, PackageName))
{
auto Registry = &FModuleManager::LoadModuleChecked<FAssetRegistryModule>(AssetRegistryConstants::ModuleName).Get();
if (FPackageName::DoesPackageExist(*PackageName))
{
// Package already exists, so try and import over the top of it, if it doesn't already have a source file path
TArray<FAssetData> Assets;
if (Registry->GetAssetsByPackageName(*PackageName, Assets))
{
if (Assets.Num() > 0)
{
if (Assets[0].GetAsset()->IsA(UDialogueVoice::StaticClass()))
{
return Cast<UDialogueVoice>(Assets[0].GetAsset());
}
}
}
}
}
return nullptr;
}
void FSUDSEditorVoiceOverTools::GenerateWaveAssets(USUDSScript* Script, EObjectFlags Flags, TMap<FString, UDialogueVoice*> UnsavedVoices, FSUDSMessageLogger* Logger)
{
// We need to identify specific lines of dialogue to specify a wave asset to go with them, which means we
// need to use the Text ID, just like we do for translations. This means that really, you shouldn't start to
// assign sounds to wave assets until you've written the text IDs back to the script
auto Registry = &FModuleManager::LoadModuleChecked<FAssetRegistryModule>(AssetRegistryConstants::ModuleName).Get();
FString Prefix;
FString ParentDir;
if (auto Settings = GetDefault<USUDSEditorSettings>())
{
Prefix = FString::Printf(TEXT("%s%s_"), *Settings->DialogueWaveAssetPrefix, *GetScriptNameAsPrefix(Script));
ParentDir = GetWaveOutputDir(Script);
}
else
{
Logger->Logf(ELogVerbosity::Error, TEXT("Unable to generate wave assets, settings not found"));
return;
}
for (auto Node : Script->GetNodes())
{
if (auto Line = Cast<USUDSScriptNodeText>(Node))
{
// We use the TextID to identify a specific line, just like for translation
FString TextID = Line->GetTextID();
// Remove the '@' characters from the text ID for creating the asset name, they just turn into excessive '_'s
const FRegexPattern TextIDPattern(TEXT("\\@([0-9a-fA-F]+)\\@"));
FRegexMatcher TextIDRegex(TextIDPattern, TextID);
if (TextIDRegex.FindNext())
{
TextID = TextIDRegex.GetCaptureGroup(1);
}
// All Dialogue Voice assets will be created in their own package
const FString SanitizedName = FString::Printf(TEXT("%s%s"),
*Prefix,
*ObjectTools::SanitizeObjectName(TextID));
const FString PackageName = UPackageTools::SanitizePackageName(ParentDir / SanitizedName);
UDialogueWave* WaveAsset = nullptr;
if (FPackageName::DoesPackageExist(*PackageName))
{
// Package already exists, so try and import over the top of it, if it doesn't already have a source file path
TArray<FAssetData> Assets;
if (Registry->GetAssetsByPackageName(*PackageName, Assets))
{
if (Assets.Num() > 0)
{
if (!Assets[0].GetAsset()->IsA(UDialogueWave::StaticClass()))
{
Logger->Logf(ELogVerbosity::Error,
TEXT(
"Asset %s already exists but is not a Dialogue Wave! Cannot replace, please move aside and re-import script."),
*PackageName);
continue;
}
else
{
WaveAsset = Cast<UDialogueWave>(Assets[0].GetAsset());
}
}
}
}
UDialogueVoice* SpeakerVoice = nullptr;
// Finding speaker assets via FAssetRegistryModule does not work on unsaved assets, so we need to check
// the unsaved voices that have been created in the previous step first
if (auto pSV = UnsavedVoices.Find(Line->GetSpeakerID()))
{
SpeakerVoice = *pSV;
}
if (!SpeakerVoice)
{
// Now try assets
SpeakerVoice = FindSpeakerVoice(Script, Line->GetSpeakerID());
}
if (!SpeakerVoice)
{
FString SpeakerPackageName;
GetSpeakerVoicePackageName(Script, Line->GetSpeakerID(), SpeakerPackageName);
Logger->Logf(ELogVerbosity::Error, TEXT("Error: speaker voice asset for %s is missing, expected to be at %s"), *Line->GetSpeakerID(), *SpeakerPackageName);
continue;
}
UPackage* Package = CreatePackage(*PackageName);
if (!ensure(Package))
{
Logger->Logf(ELogVerbosity::Error,
TEXT("Failed to create/retrieve package for wave asset %s"),
*PackageName);
}
else
{
FString NativeLangText = Line->GetText().ToString();
USoundWave* SoundWave = nullptr;
if (!WaveAsset)
{
//Logger->Logf(ELogVerbosity::Display, TEXT("Creating wave asset %s"), *PackageName);
WaveAsset = NewObject<UDialogueWave>(Package, FName(SanitizedName), Flags);
FAssetRegistryModule::AssetCreated(WaveAsset);
}
else
{
// We will always update the existing asset, but will warn if the text is changing
if (WaveAsset->ContextMappings.Num() > 0)
{
auto Mapping = WaveAsset->ContextMappings[0];
if (IsValid(Mapping.SoundWave))
{
SoundWave = Mapping.SoundWave;
// This is OK if the context is what we were going to create anyway, we'll just leave it alone
if (Mapping.Context.Speaker != SpeakerVoice ||
WaveAsset->SpokenText != NativeLangText)
{
// No good, user has assigned sound to this dialogue wave, but we need to change it to
// a different speaker / line
Logger->Logf(ELogVerbosity::Error,
TEXT(
"Dialogue Wave %s has an assigned Sound Wave but the speaker or text has changed. You should update the sound wave!"),
*PackageName);
}
}
}
}
// Set spoken text - native language only. Dialogue Wave handles the localisation of this separately,
// we can't link it to our String Table unfortunately.
WaveAsset->SpokenText = NativeLangText;
// Set dialogue context
// We need a Speaker Voice, we'll leave the sound and targets blank
WaveAsset->UpdateContext(WaveAsset->ContextMappings[0], SoundWave, SpeakerVoice, TArray<UDialogueVoice*>());
// Now assign the wave to the line
Line->SetWave(WaveAsset);
Script->MarkPackageDirty();
Package->FullyLoad();
WaveAsset->MarkPackageDirty();
}
}
}
}
FString FSUDSEditorVoiceOverTools::GetVoiceOutputDir(USUDSScript* Script)
{
if (auto Settings = GetDefault<USUDSEditorSettings>())
{
const FString PackagePath = FPackageName::GetLongPackagePath(Script->GetOuter()->GetOutermost()->GetPathName());
const FString ScriptPrefix = GetScriptNameAsPrefix(Script);
return Settings->GetVoiceOutputDir(PackagePath, ScriptPrefix);
}
return FString();
}
FString FSUDSEditorVoiceOverTools::GetWaveOutputDir(USUDSScript* Script)
{
if (auto Settings = GetDefault<USUDSEditorSettings>())
{
const FString PackagePath = FPackageName::GetLongPackagePath(Script->GetOuter()->GetOutermost()->GetPathName());
const FString ScriptPrefix = GetScriptNameAsPrefix(Script);
return Settings->GetWaveOutputDir(PackagePath, ScriptPrefix);
}
return FString();
}
FString FSUDSEditorVoiceOverTools::GetScriptNameAsPrefix(USUDSScript* Script)
{
FString Name = Script->GetName();
if (auto Settings = GetDefault<USUDSEditorSettings>())
{
if (Settings->StripScriptPrefixesWhenGeneratingNames)
{
int32 Index = INDEX_NONE;
if (Name.FindChar('_', Index))
{
if (Index < Name.Len() - 1)
{
Name = Name.RightChop(Index + 1);
}
}
}
}
return Name;
}

View File

@@ -0,0 +1,93 @@
// Copyright Steve Streeting 2022
// Released under the MIT license https://opensource.org/license/MIT/
#include "SUDSMessageLogger.h"
#include "IMessageLogListing.h"
#include "MessageLogModule.h"
#include "Modules/ModuleManager.h"
FSUDSMessageLogger::~FSUDSMessageLogger()
{
if (bWriteToMessageLog)
{
//Always clear the old message after an import or re-import
const TCHAR* LogTitle = TEXT("SUDS");
FMessageLogModule& MessageLogModule = FModuleManager::LoadModuleChecked<FMessageLogModule>("MessageLog");
TSharedPtr<class IMessageLogListing> LogListing = MessageLogModule.GetLogListing(LogTitle);
LogListing->SetLabel(FText::FromString("SUDS"));
// Should NOT clear messages here, otherwise when FSUDSMessageLogger is used multiple times in the import process,
// each one is clearing messages from previous stages of the import.
//LogListing->ClearMessages();
if(ErrorMessages.Num() > 0)
{
LogListing->AddMessages(MoveTemp(ErrorMessages));
LogListing->NotifyIfAnyMessages(NSLOCTEXT("SUDS", "ImportErrors", "There were issues with the import."), EMessageSeverity::Warning);
MessageLogModule.OpenMessageLog(LogTitle);
}
}
}
bool FSUDSMessageLogger::HasErrors() const
{
for (const TSharedRef<FTokenizedMessage>& Msg : ErrorMessages)
{
if (Msg->GetSeverity() == EMessageSeverity::Error)
{
return true;
}
}
return false;
}
int FSUDSMessageLogger::NumErrors() const
{
int Errs = 0;
for (const TSharedRef<FTokenizedMessage>& Msg : ErrorMessages)
{
if (Msg->GetSeverity() == EMessageSeverity::Error)
{
++Errs;
}
}
return Errs;
}
bool FSUDSMessageLogger::HasWarnings() const
{
for (const TSharedRef<FTokenizedMessage>& Msg : ErrorMessages)
{
if (Msg->GetSeverity() == EMessageSeverity::Warning)
{
return true;
}
}
return false;
}
int FSUDSMessageLogger::NumWarnings() const
{
int Count = 0;
for (const TSharedRef<FTokenizedMessage>& Msg : ErrorMessages)
{
if (Msg->GetSeverity() == EMessageSeverity::Warning)
{
++Count;
}
}
return Count;
}
void FSUDSMessageLogger::AddMessage(EMessageSeverity::Type Severity, const FText& Text)
{
ErrorMessages.Add(FTokenizedMessage::Create(Severity, Text));
}
void FSUDSMessageLogger::ClearMessages()
{
const TCHAR* LogTitle = TEXT("SUDS");
FMessageLogModule& MessageLogModule = FModuleManager::LoadModuleChecked<FMessageLogModule>("MessageLog");
TSharedPtr<class IMessageLogListing> LogListing = MessageLogModule.GetLogListing(LogTitle);
LogListing->SetLabel(FText::FromString("SUDS"));
LogListing->ClearMessages();
}

View File

@@ -0,0 +1,134 @@
// Copyright Steve Streeting 2022
// Released under the MIT license https://opensource.org/license/MIT/
#include "SUDSScriptActions.h"
#include "SUDSEditorScriptTools.h"
#include "SUDSEditorSettings.h"
#include "SUDSEditorToolkit.h"
#include "SUDSEditorVoiceOverTools.h"
#include "SUDSMessageLogger.h"
#include "SUDSScript.h"
#include "ToolMenuSection.h"
#include "EditorFramework/AssetImportData.h"
#include "Misc/MessageDialog.h"
#include "Widgets/Views/SListView.h"
FText FSUDSScriptActions::GetName() const
{
return INVTEXT("SUDS Script");
}
FString FSUDSScriptActions::GetObjectDisplayName(UObject* Object) const
{
return Object->GetName();
}
UClass* FSUDSScriptActions::GetSupportedClass() const
{
return USUDSScript::StaticClass();
}
FColor FSUDSScriptActions::GetTypeColor() const
{
return FColor::Orange;
}
uint32 FSUDSScriptActions::GetCategories()
{
return EAssetTypeCategories::Misc;
}
void FSUDSScriptActions::GetResolvedSourceFilePaths(const TArray<UObject*>& TypeAssets,
TArray<FString>& OutSourceFilePaths) const
{
for (auto& Asset : TypeAssets)
{
const auto Script = CastChecked<USUDSScript>(Asset);
if (Script->AssetImportData)
{
Script->AssetImportData->ExtractFilenames(OutSourceFilePaths);
}
}
}
bool FSUDSScriptActions::HasActions(const TArray<UObject*>& InObjects) const
{
return true;
}
void FSUDSScriptActions::OpenAssetEditor(const TArray<UObject*>& InObjects,
TSharedPtr<IToolkitHost> EditWithinLevelEditor)
{
MakeShared<FSUDSEditorToolkit>()->InitEditor(InObjects);
}
void FSUDSScriptActions::GetActions(const TArray<UObject*>& InObjects, FToolMenuSection& Section)
{
const auto Scripts = GetTypedWeakObjectPtrs<USUDSScript>(InObjects);
Section.AddMenuEntry(
"WriteBackTextIDs",
NSLOCTEXT("SUDS", "WriteBackTextIDs", "Write Back String Keys"),
NSLOCTEXT("SUDS",
"WriteBackTextIDsTooltip",
"Write string table keys back to source script to make them constant for localisation."),
FSlateIcon(FAppStyle::GetAppStyleSetName(), "Icons.Details"),
FUIAction(
FExecuteAction::CreateSP(this, &FSUDSScriptActions::WriteBackTextIDs, Scripts),
FCanExecuteAction()
)
);
Section.AddMenuEntry(
"GenerateVOAssets",
NSLOCTEXT("SUDS", "GenerateVOAssets", "Generate Voice Assets"),
NSLOCTEXT("SUDS",
"GenerateVOAssetsTooltip",
"Generate Dialogue Voice / Dialogue Wave assets for the selected scripts"),
FSlateIcon(FAppStyle::GetAppStyleSetName(), "Icons.Toolbar.Export"),
FUIAction(
FExecuteAction::CreateSP(this, &FSUDSScriptActions::GenerateVOAssets, Scripts),
FCanExecuteAction()
)
);
}
void FSUDSScriptActions::WriteBackTextIDs(TArray<TWeakObjectPtr<USUDSScript>> Scripts)
{
if (FMessageDialog::Open(EAppMsgType::YesNo,
FText::FromString(
"Are you sure you want to write string keys back to the selected scripts?"))
== EAppReturnType::Yes)
{
FSUDSMessageLogger::ClearMessages();
FSUDSMessageLogger Logger;
for (auto Script : Scripts)
{
if (Script.IsValid())
{
FSUDSEditorScriptTools::WriteBackTextIDs(Script.Get(), Logger);
}
}
}
}
void FSUDSScriptActions::GenerateVOAssets(TArray<TWeakObjectPtr<USUDSScript>> Scripts)
{
if (FMessageDialog::Open(EAppMsgType::YesNo,
FText::FromString(
"Are you sure you want to generate Dialogue Voice / Dialogue Wave assets for the selected scripts?"))
== EAppReturnType::Yes)
{
EObjectFlags Flags = RF_Public | RF_Standalone | RF_Transactional;
FSUDSMessageLogger::ClearMessages();
FSUDSMessageLogger Logger;
for (auto WeakScript : Scripts)
{
if (auto Script = WeakScript.Get())
{
FSUDSEditorVoiceOverTools::GenerateAssets(Script, Flags, &Logger);
}
}
}
}

View File

@@ -0,0 +1,262 @@
// Copyright Steve Streeting 2022
// Released under the MIT license https://opensource.org/license/MIT/
#include "SUDSScriptFactory.h"
#include "PackageTools.h"
#include "SUDSEditorSettings.h"
#include "SUDSEditorVoiceOverTools.h"
#include "SUDSMessageLogger.h"
#include "SUDSScript.h"
#include "AssetRegistry/AssetRegistryModule.h"
#include "EditorFramework/AssetImportData.h"
#include "Internationalization/StringTable.h"
#include "Editor.h"
#include "ObjectTools.h"
#include "Internationalization/StringTableCore.h"
USUDSScriptFactory::USUDSScriptFactory()
{
SupportedClass = USUDSScript::StaticClass();
bCreateNew = false;
bEditorImport = true;
bText = true;
Formats.Add(TEXT("sud;SUDS Script File"));
}
UObject* USUDSScriptFactory::FactoryCreateText(UClass* InClass,
UObject* InParent,
FName InName,
EObjectFlags Flags,
UObject* Context,
const TCHAR* Type,
const TCHAR*& Buffer,
const TCHAR* BufferEnd,
FFeedbackContext* Warn)
{
Flags |= RF_Transactional;
FSUDSMessageLogger::ClearMessages();
FSUDSMessageLogger Logger;
USUDSScript* Result = nullptr;
GEditor->GetEditorSubsystem<UImportSubsystem>()->BroadcastAssetPreImport(this, InClass, InParent, InName, Type);
const FString FactoryCurrentFilename = UFactory::GetCurrentFilename();
FString CurrentSourcePath;
FString FilenameNoExtension;
FString UnusedExtension;
FPaths::Split(FactoryCurrentFilename, CurrentSourcePath, FilenameNoExtension, UnusedExtension);
const FString LongPackagePath = FPackageName::GetLongPackagePath(InParent->GetOutermost()->GetPathName());
const FString NameForErrors(InName.ToString());
// Now parse this using utility
if(Importer.ImportFromBuffer(Buffer, BufferEnd - Buffer, NameForErrors, &Logger, false))
{
// Populate with data
Result = NewObject<USUDSScript>(InParent, InName, Flags);
UStringTable* StringTable = CreateStringTable(InParent, InName, Result, Flags, &Logger);
Importer.PopulateAsset(Result, StringTable);
// Register source info
const FMD5Hash Hash = FSUDSScriptImporter::CalculateHash(Buffer, BufferEnd - Buffer);
Result->AssetImportData->Update(FactoryCurrentFilename, Hash);
// VO assets at import time?
if (ShouldGenerateVoiceAssets(LongPackagePath))
{
FSUDSEditorVoiceOverTools::GenerateAssets(Result, Flags, &Logger);
}
}
GEditor->GetEditorSubsystem<UImportSubsystem>()->BroadcastAssetPostImport(this, Result);
return Result;
}
bool USUDSScriptFactory::ShouldGenerateVoiceAssets(const FString& PackagePath) const
{
if (auto Settings = GetDefault<USUDSEditorSettings>())
{
return Settings->ShouldGenerateVoiceAssets(PackagePath);
}
return false;
}
UStringTable* USUDSScriptFactory::CreateStringTable(UObject* ScriptParent, FName InName, USUDSScript* Script, EObjectFlags Flags, FSUDSMessageLogger* Logger)
{
auto Settings = GetDefault<USUDSEditorSettings>();
const bool bCreateSeparatePackage = Settings && Settings->bCreateStringTablesAsSeparatePackages;
const FName StringTableName = FName(InName.ToString() + "Strings");
UStringTable* Table = nullptr;
auto Registry = &FModuleManager::LoadModuleChecked<FAssetRegistryModule>(AssetRegistryConstants::ModuleName).Get();
const FString PackageDir = FPackageName::GetLongPackagePath(Script->GetOuter()->GetOutermost()->GetPathName());
const FString PackageName = UPackageTools::SanitizePackageName(PackageDir / StringTableName.ToString());
if (bCreateSeparatePackage)
{
// Create string table as its own package (.uasset)
{
// Destroy any string table packed together with the script
const FString ScriptPackageName = Script->GetPackage()->GetPathName();
TArray<FAssetData> ScriptAssets;
Registry->GetAssetsByPackageName(*ScriptPackageName, ScriptAssets);
TArray<UObject*> StringTableAssets;
for (auto& Asset : ScriptAssets)
{
if (Asset.GetClass() == UStringTable::StaticClass())
{
StringTableAssets.Add(Asset.GetAsset());
}
}
if (StringTableAssets.Num() > 0)
{
ObjectTools::ForceDeleteObjects(StringTableAssets, false );
}
}
if (FPackageName::DoesPackageExist(*PackageName))
{
// Package already exists, so try and import over the top of it, if it doesn't already have a source file path
TArray<FAssetData> Assets;
if (Registry->GetAssetsByPackageName(*PackageName, Assets))
{
if (Assets.Num() > 0)
{
if (Assets[0].GetAsset()->IsA(UStringTable::StaticClass()))
{
Table = Cast<UStringTable>(Assets[0].GetAsset());
Table->GetMutableStringTable()->ClearSourceStrings();
Table->MarkPackageDirty();
}
else
{
Logger->Logf(ELogVerbosity::Error,
TEXT(
"Asset %s already exists but is not a String Table! Cannot replace, please move aside and re-import script."),
*PackageName);
return nullptr;
}
}
}
}
if (!Table)
{
// String Table didn't exist (although package might have)
// It's safe to call CreatePackage either way, it'll return the existing one if needed
UPackage* Package = CreatePackage(*PackageName);
if (!ensure(Package))
{
Logger->Logf(ELogVerbosity::Error,
TEXT("Failed to create/retrieve package for string table %s"),
*PackageName);
}
else
{
//Logger->Logf(ELogVerbosity::Display, TEXT("Creating string table %s"), *PackageName);
// This constructor registers the string table with FStringTableRegistry
Table = NewObject<UStringTable>(Package, StringTableName, Flags);
Package->FullyLoad();
Table->MarkPackageDirty();
FAssetRegistryModule::AssetCreated(Table);
}
}
}
else
{
// Destroy separate strings package if it exists (could flip this option off after importing)
if (FPackageName::DoesPackageExist(*PackageName))
{
TArray<FAssetData> Assets;
if (Registry->GetAssetsByPackageName(*PackageName, Assets))
{
if (Assets.Num() > 0)
{
// Note: we need to force deletion because old string table will be referenced
// Unfortunately there is no ObjectTools::ForceDeleteAssets, only the inner
// ForceDeleteObjects, so we need to do it ourselves
ForceDeleteAssets(Assets);
}
}
}
// Default route, create string table inside script package
Table = NewObject<UStringTable>(ScriptParent, StringTableName, Flags);
}
return Table;
}
void USUDSScriptFactory::ForceDeleteAssets(const TArray<FAssetData>& AssetsToDelete)
{
// Copied from ObjectTools::DeleteAssets, except that we're using ForceDeleteObjects so we
// don't have to display any confusing messages about moving the string table into the shared asset
TArray<TWeakObjectPtr<UPackage>> PackageFilesToDelete;
TArray<UObject*> ObjectsToDelete;
for ( int i = 0; i < AssetsToDelete.Num(); i++ )
{
const FAssetData& AssetData = AssetsToDelete[i];
UObject *ObjectToDelete = AssetData.GetAsset({ ULevel::LoadAllExternalObjectsTag });
// Assets can be loaded even when their underlying type/class no longer exists...
if ( ObjectToDelete!=nullptr )
{
ObjectsToDelete.Add( ObjectToDelete );
}
else if ( AssetData.IsUAsset() )
{
// ... In this cases there is no underlying asset or type so remove the package itself directly after confirming it's valid to do so.
FString PackageFilename;
if( !FPackageName::DoesPackageExist( AssetData.PackageName.ToString(), &PackageFilename ) )
{
// Could not determine filename for package so we can not delete
continue;
}
UPackage* Package = FindPackage(nullptr, *AssetData.PackageName.ToString());
if ( Package )
{
PackageFilesToDelete.Add(Package);
}
}
}
int32 NumObjectsToDelete = ObjectsToDelete.Num();
if ( NumObjectsToDelete > 0 )
{
ObjectTools::ForceDeleteObjects( ObjectsToDelete, false );
}
const int32 NumPackagesToDelete = PackageFilesToDelete.Num();
if (NumPackagesToDelete > 0)
{
TArray<UPackage*> PackagePointers;
for ( const auto& PkgIt : PackageFilesToDelete )
{
UPackage* Package = PkgIt.Get();
if ( Package )
{
PackagePointers.Add(Package);
}
}
if ( PackagePointers.Num() > 0 )
{
const bool bPerformReferenceCheck = true;
ObjectTools::CleanupAfterSuccessfulDelete(PackagePointers, bPerformReferenceCheck);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,149 @@
// Copyright Steve Streeting 2022
// Released under the MIT license https://opensource.org/license/MIT/
#include "SUDSScriptReimportFactory.h"
#include "SUDSEditor.h"
#include "SUDSMessageLogger.h"
#include "SUDSScript.h"
#include "SUDSScriptNodeText.h"
#include "EditorFramework/AssetImportData.h"
#include "HAL/FileManager.h"
#include "Sound/DialogueWave.h"
USUDSScriptReimportFactory::USUDSScriptReimportFactory()
{
SupportedClass = USUDSScript::StaticClass();
bCreateNew = false;
// We need to have a unique priority vs the original factory, so go after
ImportPriority = DefaultImportPriority - 1;
}
bool USUDSScriptReimportFactory::CanReimport(UObject* Obj, TArray<FString>& OutFilenames)
{
USUDSScript* Script = Cast<USUDSScript>(Obj);
if (Script && Script->AssetImportData)
{
Script->AssetImportData->ExtractFilenames(OutFilenames);
return true;
}
return false;
}
void USUDSScriptReimportFactory::SetReimportPaths(UObject* Obj, const TArray<FString>& NewReimportPaths)
{
USUDSScript* Script = Cast<USUDSScript>(Obj);
if (Script && ensure(NewReimportPaths.Num() == 1))
{
Script->AssetImportData->UpdateFilenameOnly(NewReimportPaths[0]);
}
}
EReimportResult::Type USUDSScriptReimportFactory::Reimport(UObject* Obj)
{
USUDSScript* Script = Cast<USUDSScript>(Obj);
if (!Script)
{
return EReimportResult::Failed;
}
// Make sure file is valid and exists
const FString Filename = Script->AssetImportData->GetFirstFilename();
if (!Filename.Len() || IFileManager::Get().FileSize(*Filename) == INDEX_NONE)
{
return EReimportResult::Failed;
}
// When a new script is created, it actually lives at the same address as the incoming one. UE must re-use objects
// when you put them back at the same outer & asset name?
// This means if we want to preserve anything from the previously imported object, such as generated VO asset links,
// we need to copy those out now.
TMap<FString, UDialogueVoice*> PrevSpeakerVoices = Script->GetSpeakerVoices();
// Store the TextID -> DialogueWave, but also store the line text as well so we can detect whether it matches & warn if not
TMap<FString, TPair<FString, UDialogueWave*> > PrevWaves;
for (auto Node : Script->GetNodes())
{
if (auto TN = Cast<USUDSScriptNodeText>(Node))
{
if (auto W = TN->GetWave())
{
PrevWaves.Add(TN->GetTextID(), TPair<FString, UDialogueWave*>(TN->GetText().ToString(), W));
}
}
}
// Run the import again
EReimportResult::Type Result = EReimportResult::Failed;
bool OutCanceled = false;
if (ImportObject(Script->GetClass(), Script->GetOuter(), *Script->GetName(), RF_Public | RF_Standalone, Filename, nullptr, OutCanceled) != nullptr)
{
UE_LOG(LogSUDSEditor, Log, TEXT("Imported successfully"));
FSUDSMessageLogger Logger;
// Now, try to restore the speaker voice / line wave links from before
for (auto SpeakerID : Script->GetSpeakers())
{
if (auto pVoice = PrevSpeakerVoices.Find(SpeakerID))
{
Script->SetSpeakerVoice(SpeakerID, *pVoice);
}
}
for (auto Node : Script->GetNodes())
{
if (auto TN = Cast<USUDSScriptNodeText>(Node))
{
if (auto pWavePair = PrevWaves.Find(TN->GetTextID()))
{
// Set the wave link either way
TN->SetWave(pWavePair->Value);
// Check that the text is the same; if it's not, then lines have potentially changed
// we still live with the assignment we have (might be a minor edit) but user should be aware
if (TN->GetText().ToString() != pWavePair->Key)
{
Logger.Logf(ELogVerbosity::Error,
TEXT(
"TextID %s is linked to Dialogue Wave %s, but text has changed. Check whether this line is linked to the correct wave, and consider Writing String Keys back to script before making more script changes in future."),
*TN->GetTextID(),
*pWavePair->Value->GetName());
}
}
}
}
Script->AssetImportData->Update(Filename);
// Try to find the outer package so we can dirty it up
if (Script->GetOuter())
{
Script->GetOuter()->MarkPackageDirty();
}
else
{
Script->MarkPackageDirty();
}
Result = EReimportResult::Succeeded;
}
else
{
if (OutCanceled)
{
UE_LOG(LogSUDSEditor, Warning, TEXT("-- import canceled"));
}
else
{
UE_LOG(LogSUDSEditor, Warning, TEXT("-- import failed"));
}
Result = EReimportResult::Failed;
}
return Result;
}
int32 USUDSScriptReimportFactory::GetPriority() const
{
return ImportPriority;
}

View File

@@ -0,0 +1,24 @@
// Copyright Steve Streeting 2022
// Released under the MIT license https://opensource.org/license/MIT/
#pragma once
#include "CoreMinimal.h"
#include "Modules/ModuleInterface.h"
class FSUDSScriptActions;
class FSlateStyleSet;
DECLARE_LOG_CATEGORY_EXTERN(LogSUDSEditor, Verbose, All);
class FSUDSEditorModule : public IModuleInterface
{
public:
/** IModuleInterface implementation */
virtual void StartupModule() override;
virtual void ShutdownModule() override;
protected:
TSharedPtr<FSUDSScriptActions> ScriptActions;
TSharedPtr<FSlateStyleSet> StyleSet;
};

View File

@@ -0,0 +1,22 @@
// Copyright Steve Streeting 2022
// Released under the MIT license https://opensource.org/license/MIT/
#pragma once
#include "CoreMinimal.h"
class USUDSScript;
class USUDSScriptNode;
struct FSUDSMessageLogger;
class FSUDSEditorScriptTools
{
public:
static void WriteBackTextIDs(USUDSScript* Script, FSUDSMessageLogger& Logger);
static bool WriteBackTextIDsFromNodes(const TArray<USUDSScriptNode*> Nodes, TArray<FString>& Lines, const FString& NameForErrors, FSUDSMessageLogger& Logger);
static bool WriteBackTextID(const FText& AssetText, int LineNo, TArray<FString>& Lines, const FString& NameForErrors, FSUDSMessageLogger& Logger);
static bool WriteBackGosubID(const FString& GosubID, int LineNo, TArray<FString>& Lines, const FString& NameForErrors, FSUDSMessageLogger& Logger);
static bool TextIDCheckMatch(const FText& AssetText, const FString& SourceLine);
};

View File

@@ -0,0 +1,73 @@
// Copyright Steve Streeting 2022
// Released under the MIT license https://opensource.org/license/MIT/
#pragma once
#include "CoreMinimal.h"
#include "UObject/Object.h"
#include "SUDSEditorSettings.generated.h"
UENUM(BlueprintType)
enum class ESUDSAssetLocation : uint8
{
/// Use a single flat shared directory
SharedDirectory,
/// Use a shared base directory, but create subfolders based on the script asset name
SharedDirectorySubdir,
/// Place asset alongside the script that originated it
ScriptDirectory,
/// Place asset in a subfolder of the folder containing the script that generated it, named the same as the script
ScriptDirectorySubdir
};
class USUDSScript;
/**
* Settings for editor-specific aspects of SUDS (no effect at runtime)
*/
UCLASS(config = Editor, defaultconfig, meta=(DisplayName="SUDS Editor"))
class SUDSEDITOR_API USUDSEditorSettings : public UObject
{
GENERATED_BODY()
public:
UPROPERTY(config, EditAnywhere, Category = "Voice", meta = (Tooltip = "Where to place Dialogue Voice assets for speakers in scripts when generated", RelativeToGameContentDir, LongPackageName))
ESUDSAssetLocation DialogueVoiceAssetLocation = ESUDSAssetLocation::SharedDirectory;
UPROPERTY(config, EditAnywhere, Category = "Voice", meta = (Tooltip = "Shared directory for Dialogue Voice assets, if using a shared directory", RelativeToGameContentDir, LongPackageName))
FDirectoryPath DialogueVoiceAssetSharedDir;
UPROPERTY(config, EditAnywhere, Category = "Voice", meta = (Tooltip = "Where to place Dialogue Wave assets for speaker lines in scripts", RelativeToGameContentDir, LongPackageName))
ESUDSAssetLocation DialogueWaveAssetLocation = ESUDSAssetLocation::ScriptDirectorySubdir;
UPROPERTY(config, EditAnywhere, Category = "Voice", meta = (Tooltip = "Shared directory for Dialogue Wave assets, if using a shared directory", RelativeToGameContentDir, LongPackageName))
FDirectoryPath DialogueWaveAssetSharedDir;
UPROPERTY(config, EditAnywhere, Category = "Voice", meta = (Tooltip = "Prefix to give Dialogue Voice assets in front of their SpeakerID", RelativeToGameContentDir, LongPackageName))
FString DialogueVoiceAssetPrefix = "DV_";
UPROPERTY(config, EditAnywhere, Category = "Voice", meta = (Tooltip = "Prefix to give Dialogue Wave assets in front of their SpeakerID", RelativeToGameContentDir, LongPackageName))
FString DialogueWaveAssetPrefix = "DW_";
UPROPERTY(config, EditAnywhere, Category = "Voice", meta = (Tooltip = "When generating subdirectories and wave asset names from script names, whether to strip characters before the first '_' to avoid including script prefix", RelativeToGameContentDir, LongPackageName))
bool StripScriptPrefixesWhenGeneratingNames = true;
UPROPERTY(config, EditAnywhere, Category = "Voice", AdvancedDisplay, meta = (Tooltip = "Whether to auto-generate Dialogue Voice/Wave assets for ALL dialogue scripts on import. Note: you can always generate VO assets manually."))
bool AlwaysAutoGenerateVoiceOverAssetsOnImport = false;
UPROPERTY(config, EditAnywhere, Category = "Voice", AdvancedDisplay, meta = (Tooltip = "Auto-generate Dialogue Voice/Wave assets for scripts in these directories (and subdirectories) on import. Note: you can always generate VO assets manually.", RelativeToGameContentDir, LongPackageName))
TArray<FDirectoryPath> DirectoriesToAutoGenerateVoiceOverAssetsOnImport;
UPROPERTY(config, EditAnywhere, Category = "Choices", meta = (Tooltip = "Whether to generate a spoken line for choices (default false)."))
bool AlwaysGenerateSpeakerLinesFromChoices = false;
UPROPERTY(config, EditAnywhere, Category = "Choices", meta = (Tooltip = "The SpeakerID to use for speaker lines generated from choices"))
FString SpeakerIdForGeneratedLinesFromChoices = "Player";
UPROPERTY(config, EditAnywhere, Category = "Assets", AdvancedDisplay, meta = (Tooltip = "Whether to create string tables as a separate package (.uasset) from the SUDS Script, which will cause them to appear separately in the Content Browser (requires script re-import)"))
bool bCreateStringTablesAsSeparatePackages = true;
USUDSEditorSettings() {}
bool ShouldGenerateVoiceAssets(const FString& PackagePath) const;
FString GetVoiceOutputDir(const FString& PackagePath, const FString& ScriptName) const;
FString GetWaveOutputDir(const FString& PackagePath, const FString& ScriptName) const;
static FString GetOutputDir(ESUDSAssetLocation Location, const FString& SharedPath, const FString& PackagePath, const FString& ScriptName);
};

View File

@@ -0,0 +1,34 @@
// Copyright Steve Streeting 2022
// Released under the MIT license https://opensource.org/license/MIT/
#pragma once
#include "CoreMinimal.h"
#include "SUDSScriptNodeText.h"
#include "Sound/DialogueVoice.h"
struct FSUDSMessageLogger;
class USUDSScript;
class SUDSEDITOR_API FSUDSEditorVoiceOverTools
{
public:
static void GenerateAssets(USUDSScript* Script, EObjectFlags Flags, FSUDSMessageLogger* Logger);
protected:
static void GenerateVoiceAssets(USUDSScript* Script,
EObjectFlags Flags,
FSUDSMessageLogger *Logger,
TMap<FString, UDialogueVoice*> &OutCreatedVoices);
static void GenerateWaveAssets(USUDSScript* Script, EObjectFlags Flags, TMap<FString, UDialogueVoice*>, FSUDSMessageLogger* Logger);
static bool GetSpeakerVoicePackageName(USUDSScript* Script, const FString& SpeakerID, FString& OutPackageName);
static bool GetSpeakerVoiceAssetNames(USUDSScript* Script,
const FString& SpeakerID,
FString& OutPackageName,
FString& OutAssetName);
static UDialogueVoice* FindSpeakerVoice(USUDSScript* Script, const FString& SpeakerID);
static FString GetScriptNameAsPrefix(USUDSScript* Script);
static FString GetVoiceOutputDir(USUDSScript* Script);
static FString GetWaveOutputDir(USUDSScript* Script);
};

View File

@@ -0,0 +1,60 @@
// Copyright Steve Streeting 2022
// Released under the MIT license https://opensource.org/license/MIT/
#pragma once
#include "CoreMinimal.h"
#include "Logging/TokenizedMessage.h"
class FTokenizedMessage;
struct SUDSEDITOR_API FSUDSMessageLogger
{
protected:
TArray<TSharedRef<FTokenizedMessage>> ErrorMessages;
bool bWriteToMessageLog = true;
public:
FSUDSMessageLogger() {}
FSUDSMessageLogger(bool bWriteToMsg) : bWriteToMessageLog(bWriteToMsg) {}
~FSUDSMessageLogger();
void SetWriteToMessageLog(bool bWrite) { bWriteToMessageLog = bWrite; }
bool HasErrors() const;
int NumErrors() const;
bool HasWarnings() const;
int NumWarnings() const;
bool GetWriteToMessageLog() const { return bWriteToMessageLog; }
void AddMessage(EMessageSeverity::Type Severity, const FText& Text);
#if ENGINE_MAJOR_VERSION >= 5 && ENGINE_MINOR_VERSION >= 6
template <typename... Types>
FORCEINLINE void Logf(ELogVerbosity::Type Verbosity, UE::Core::TCheckedFormatString<FString::FmtCharType, Types...> Fmt, Types... Args)
#else
template <typename FmtType, typename... Types>
FORCEINLINE void Logf(ELogVerbosity::Type Verbosity, const FmtType& Fmt, Types... Args)
#endif
{
EMessageSeverity::Type Sev = EMessageSeverity::Info;
switch(Verbosity)
{
case ELogVerbosity::Fatal:
case ELogVerbosity::Error:
Sev = EMessageSeverity::Error;
break;
case ELogVerbosity::Warning:
Sev = EMessageSeverity::Warning;
break;
default: ;
}
AddMessage(Sev, FText::FromString(FString::Printf(Fmt, Args...)));
}
const TArray<TSharedRef<FTokenizedMessage>>& GetErrorMessages() const { return ErrorMessages; }
/// Clear messages in preparation for an import
static void ClearMessages();
};

View File

@@ -0,0 +1,37 @@
// Copyright Steve Streeting 2022
// Released under the MIT license https://opensource.org/license/MIT/
#pragma once
#include "CoreMinimal.h"
#include "AssetTypeActions_Base.h"
#include "SUDSScriptNodeGosub.h"
#include "SUDSScriptNodeText.h"
class USUDSScriptNode;
class USUDSScript;
struct FSUDSMessageLogger;
/**
*
*/
class FSUDSScriptActions : public FAssetTypeActions_Base
{
public:
virtual FText GetName() const override;
virtual FString GetObjectDisplayName(UObject* Object) const override;
virtual UClass* GetSupportedClass() const override;
virtual FColor GetTypeColor() const override;
virtual uint32 GetCategories() override;
virtual void GetResolvedSourceFilePaths(const TArray<UObject*>& TypeAssets,
TArray<FString>& OutSourceFilePaths) const override;
virtual bool IsImportedAsset() const override { return true; }
virtual void GetActions(const TArray<UObject*>& InObjects, FToolMenuSection& Section) override;
virtual bool HasActions(const TArray<UObject*>& InObjects) const override;
virtual void OpenAssetEditor(const TArray<UObject*>& InObjects,
TSharedPtr<IToolkitHost> EditWithinLevelEditor) override;
protected:
void WriteBackTextIDs(TArray<TWeakObjectPtr<USUDSScript>> Scripts);
void GenerateVOAssets(TArray<TWeakObjectPtr<USUDSScript>> Scripts);
};

View File

@@ -0,0 +1,37 @@
// Copyright Steve Streeting 2022
// Released under the MIT license https://opensource.org/license/MIT/
#pragma once
#include "CoreMinimal.h"
#include "SUDSScriptImporter.h"
#include "Factories/Factory.h"
#include "SUDSScriptFactory.generated.h"
/**
*
*/
UCLASS()
class SUDSEDITOR_API USUDSScriptFactory : public UFactory
{
GENERATED_BODY()
public:
USUDSScriptFactory();
protected:
virtual UObject* FactoryCreateText(UClass* InClass,
UObject* InParent,
FName InName,
EObjectFlags Flags,
UObject* Context,
const TCHAR* Type,
const TCHAR*& Buffer,
const TCHAR* BufferEnd,
FFeedbackContext* Warn) override;
bool ShouldGenerateVoiceAssets(const FString& PackagePath) const;
FSUDSScriptImporter Importer;
void ForceDeleteAssets(const TArray<FAssetData>& Assets);
UStringTable* CreateStringTable(UObject* ScriptParent, FName InName, USUDSScript* Script, EObjectFlags Flags, FSUDSMessageLogger* Logger);
};

View File

@@ -0,0 +1,520 @@
// Copyright Steve Streeting 2022
// Released under the MIT license https://opensource.org/license/MIT/
#pragma once
#include "CoreMinimal.h"
#include "SUDSExpression.h"
struct FSUDSMessageLogger;
class USUDSScript;
DECLARE_LOG_CATEGORY_EXTERN(LogSUDSImporter, Verbose, All);
struct SUDSEDITOR_API FSUDSParsedEdge
{
public:
/// Text associated with this edge (if a player choice option)
FString Text;
/// Identifier of the text, for the string table
FString TextID;
/// Metadata associated with text, for translator comments
TMap<FName, FString> TextMetadata;
/// The line this edge was created on
int SourceLineNo;
/// Condition expression that applies to this edge (for select nodes)
FSUDSExpression ConditionExpression;
/// Custom metadata for a node which the user can set. Can be used to annotate choice lines
/// Could be used to disable a choice if you don't have the stats to take it, or anything else
/// where you need a bit of extra information about a line that doesn't suit setting a dialogue variable for.
TMap<FName, FSUDSExpression> UserMetadata;
int SourceNodeIdx = -1;
int TargetNodeIdx = -1;
FSUDSParsedEdge(int LineNo) : SourceLineNo(LineNo){}
FSUDSParsedEdge(int FromNodeIdx, int ToNodeIdx, int LineNo, const FString& InText, const FString& InTextID, const TMap<FName, FString>& Metadata, const TMap<FName, FSUDSExpression>& UserMeta)
: Text(InText),
TextID(InTextID),
TextMetadata(Metadata),
SourceLineNo(LineNo),
UserMetadata(UserMeta),
SourceNodeIdx(FromNodeIdx),
TargetNodeIdx(ToNodeIdx)
{
}
FSUDSParsedEdge(int FromNodeIdx, int ToNodeIdx, int LineNo)
: SourceLineNo(LineNo),
SourceNodeIdx(FromNodeIdx),
TargetNodeIdx(ToNodeIdx)
{
}
void Reset()
{
*this = FSUDSParsedEdge(-1);
}
};
enum class ESUDSParsedNodeType : uint8
{
/// Text node, displaying a line of dialogue
Text,
/// Choice node, displaying a series of user choices which navigate to other nodes
Choice,
/// Select node, automatically selecting one which navigates to another node based on state (also Random)
Select,
/// Goto node, redirects execution somewhere else
/// Gotos are only nodes in the parsing structure, because they need to be discoverable as a fallthrough destination
/// When converting to runtime use they just become edges
Goto,
/// Gosub node, acts like goto except stores return location
Gosub,
/// Return node, returns from a gosub
Return,
/// Set variable node
SetVariable,
/// Event node
Event
};
/// Intermediate parsed node from script text
/// This will be converted into a final asset later
struct SUDSEDITOR_API FSUDSParsedNode
{
public:
ESUDSParsedNodeType NodeType;
int OriginalIndent;
/// Identifier is speaker ID, goto label, variable name etc
FString Identifier;
/// Text in native language
FString Text;
/// Identifier of the text, for the string table
FString TextID;
/// Metadata associated with text, for translator comments
TMap<FName, FString> TextMetadata;
/// Expression, for nodes that use it (e.g. set)
FSUDSExpression Expression;
/// Event arguments, for event nodes
TArray<FSUDSExpression> EventArgs;
/// Labels which lead to this node
TArray<FString> Labels;
/// Edges leading to other nodes
TArray<FSUDSParsedEdge> Edges;
/// The line this node was created on
int SourceLineNo;
/// Whether this is a valid fall-through target
bool AllowFallthrough = true;
/// Custom metadata for a node which the user can set. Can be used to annotate speaker lines
TMap<FName, FSUDSExpression> UserMetadata;
// Path hierarchy of choice nodes leading to this node, of the form "/C002/C006" etc, not including this node index
// This helps us identify valid fallthroughs
FString ChoicePath;
// Path hierarchy of select nodes leading to this node, of the form "/S002/S006" etc, not including this node index
// This helps us identify valid fallthroughs
FString ConditionalPath;
/// Although multiple edges can lead here, this index is for the auto-connected parent (may be nothing)
int ParentNodeIdx = -1;
FSUDSParsedNode(ESUDSParsedNodeType InNodeType, int Indent, int LineNo) : NodeType(InNodeType),
OriginalIndent(Indent),
SourceLineNo(LineNo)
{
}
FSUDSParsedNode(const FString& Label,
const FString& GosubID,
int Indent,
int LineNo) : NodeType(ESUDSParsedNodeType::Gosub),
OriginalIndent(Indent),
Identifier(Label),
TextID(GosubID),
SourceLineNo(LineNo)
{
}
FSUDSParsedNode(const FString& InSpeaker, const FString& InText, const FString& InTextID, const TMap<FName, FString>& Metadata, const TMap<FName, FSUDSExpression>& UserMeta, int Indent, int LineNo)
: NodeType(ESUDSParsedNodeType::Text),
OriginalIndent(Indent),
Identifier(InSpeaker),
Text(InText),
TextID(InTextID),
TextMetadata(Metadata),
SourceLineNo(LineNo),
UserMetadata(UserMeta)
{
}
FSUDSParsedNode(const FString& GotoLabel, int Indent, int LineNo)
: NodeType(ESUDSParsedNodeType::Goto), OriginalIndent(Indent), Identifier(GotoLabel), SourceLineNo(LineNo)
{
}
FSUDSParsedNode(const FString& VariableName, const FSUDSExpression& InExpr, int Indent, int LineNo)
: NodeType(ESUDSParsedNodeType::SetVariable),
OriginalIndent(Indent),
Identifier(VariableName),
Expression(InExpr),
SourceLineNo(LineNo)
{
}
FSUDSParsedNode(const FString& VariableName,
const FSUDSExpression& InExpr,
const FString& InTextID,
int Indent,
int LineNo)
: NodeType(ESUDSParsedNodeType::SetVariable),
OriginalIndent(Indent),
Identifier(VariableName),
TextID(InTextID),
Expression(InExpr),
SourceLineNo(LineNo)
{
}
};
class SUDSEDITOR_API FSUDSScriptImporter
{
public:
bool ImportFromBuffer(const TCHAR* Buffer, int32 Len, const FString& NameForErrors, FSUDSMessageLogger* Logger, bool bSilent);
void PopulateAsset(USUDSScript* Asset, UStringTable* StringTable);
static FMD5Hash CalculateHash(const TCHAR* Buffer, int32 Len);
static const FString EndGotoLabel;
protected:
static const FString TreePathSeparator;
enum class EConditionalStage : uint8
{
IfStage,
ElseIfStage,
ElseStage,
RandomStage,
RandomOptionStage
};
enum class EConditionalParent : uint8
{
Select,
ElseIfStage,
ElseStage
};
// Struct for tracking if/elseif blocks
struct ConditionalContext
{
/// Index of parent select node where else will be added
int SelectNodeIdx = -1;
/// Previous block index (parent for nesting)
int PreviousBlockIdx = -1;
/// Track whether we're in if/elseif/else
EConditionalStage Stage;
/// String identifying the current condition; for elseif or else contains original "if" context
FString ConditionPathElement;
ConditionalContext(int InSelectNodeIdx, int InPrevBlockIdx, EConditionalStage InStage, const FString& InCondStr) :
SelectNodeIdx(InSelectNodeIdx),
PreviousBlockIdx(InPrevBlockIdx),
Stage(InStage),
ConditionPathElement(InCondStr)
{
}
};
/// Struct for tracking indents
struct IndentContext
{
public:
// The index of the Node which is the parent of this context
// This potentially changes every time a sequential text node is encountered in the same context, so it's
// always pointing to the last node encountered at this level, for connection
int LastNodeIdx = -1;
/// The outermost indent level where this context lives
/// You can indent things that don't create a new context, e.g.
/// 1. Indent a text line under another text line: this is the same as no indent, just a continuation
/// 2. Indent choices or conditions under a text line
/// This is just good for readability, but does not create a new context, it's just a linear sequence
/// Therefore the ThresholdIndent tracks the outermost indent relating to the current linear sequence, to know
/// when you do in fact need to pop the current context off the stack.
int ThresholdIndent = 0;
int LastTextNodeIdx = -1;
/// The path entry for this indent, to be combined with all previous levels to provide full path context
FString PathEntry;
IndentContext(int NodeIdx, int Indent, const FString& Path) : LastNodeIdx(NodeIdx), ThresholdIndent(Indent), LastTextNodeIdx(-1), PathEntry(Path) {}
};
/// A tree of nodes. Contained to separate header nodes from body nodes
struct ParsedTree
{
public:
/// The indent context stack representing where we are in the indentation tree while parsing
/// There must always be 1 level (root)
TArray<IndentContext> IndentLevelStack;
/// When encountering conditions and choice lines, we are building up details for an edge to another node, but
/// we currently don't know the target node. We keep these pending details here
int EdgeInProgressNodeIdx = -1;
int EdgeInProgressEdgeIdx = -1;
/// List of all nodes, appended to as parsing progresses
/// Ordering is important, these nodes must be in the order encountered in the file
TArray<FSUDSParsedNode> Nodes;
/// Record of goto labels to node index, built up during parsing (forward refs are OK so not complete until end of parsing)
TMap<FString, int> GotoLabelList;
/// Goto labels which have been encountered but we haven't found a destination yet
TArray<FString> PendingGotoLabels;
/// Goto labels that lead directly to another goto and thus are just aliases
TMap<FString, FString> AliasedGotoLabels;
/// Conditional blocks
/// "if" creates a new context, uses current as parent
/// "elseif" and "else" also create new contexts, but copies parent from current (sibling)
/// "endif" ends the context
/// You cannot have conditionals that were started in an indent context ending outside it
TArray<ConditionalContext> ConditionalBlocks;
/// Index of the current conditional block, if any
int CurrentConditionalBlockIdx = -1;
void Reset()
{
IndentLevelStack.Reset();
EdgeInProgressNodeIdx = EdgeInProgressEdgeIdx -1;
Nodes.Reset();
GotoLabelList.Reset();
PendingGotoLabels.Reset();
AliasedGotoLabels.Reset();
ConditionalBlocks.Reset();
CurrentConditionalBlockIdx = -1;
}
};
ParsedTree HeaderTree;
ParsedTree BodyTree;
struct ParsedMetadata
{
public:
FName Key;
FString Value;
int IndentLevel;
ParsedMetadata(const FName& InKey, const FString& InValue, int InIndentLevel)
: Key(InKey),
Value(InValue),
IndentLevel(InIndentLevel)
{
}
};
/// Text Metadata applied to speaker lines / choices until reset
/// For each key there's a stack of metadata, with most indented at the top
TMap<FName, TArray<ParsedMetadata>> PersistentMetadata;
/// Text Metadata applied just to the next speaker line or choice
TMap<FName, ParsedMetadata> TransientMetadata;
/// User metadata for the next speaker line or choice
TMap<FName, FSUDSExpression> UserMetadata;
/// List of speakers, detected during parsing of lines of text
TArray<FString> ReferencedSpeakers;
const int TabIndentValue = 4;
bool bHeaderDone = false;
bool bTooLateForHeader = false;
bool bHeaderInProgress = false;
TOptional<bool> bOverrideGenerateSpeakerLineForChoice;
TOptional<FString> OverrideChoiceSpeakerID;
bool bTextInProgress = false;
int ChoiceUniqueId = 0;
/// For generating text IDs
int TextIDHighestNumber = 0;
/// For generating gosub IDs
int GosubIDHighestNumber = 0;
/// Parse a single line
bool ParseLine(const FStringView& Line, int LineNo, const FString& NameForErrors, FSUDSMessageLogger* Logger, bool bSilent);
bool ParseHeaderLine(const FStringView& Line, int IndentLevel, int LineNo, const FString& NameForErrors, FSUDSMessageLogger* Logger, bool bSilent);
bool ParseBodyLine(const FStringView& Line, int IndentLevel, int LineNo, const FString& NameForErrors, FSUDSMessageLogger* Logger, bool bSilent);
bool ParseCommentMetadataLine(const FStringView& Line, int IndentLevel, int LineNo, const FString& NameForErrors, FSUDSMessageLogger* Logger, bool bSilent);
bool IsLastNodeOfType(const ParsedTree& Tree, ESUDSParsedNodeType Type);
bool ParseChoiceLine(const FStringView& Line, ParsedTree& Tree, int IndentLevel, int LineNo, const FString& NameForErrors, FSUDSMessageLogger*
Logger,
bool bSilent);
FSUDSParsedEdge* GetEdgeInProgress(ParsedTree& Tree);
void EnsureChoiceNodeExistsAboveSelect(ParsedTree& Tree, int IndentLevel, int LineNo);
static bool IsConditionalLine(const FStringView& Line);
bool ParseConditionalLine(const FStringView& Line, ParsedTree& Tree, int IndentLevel, int LineNo, const FString& NameForErrors, FSUDSMessageLogger* Logger, bool bSilent);
bool ParseIfLine(const FStringView& Line,
ParsedTree& Tree,
const FString& ConditionStr,
int IndentLevel,
int LineNo,
const FString& NameForErrors,
FSUDSMessageLogger* Logger,
bool bSilent);
bool ParseElseIfLine(const FStringView& Line,
ParsedTree& Tree,
const FString& ConditionStr,
int IndentLevel,
int LineNo,
const FString& NameForErrors,
FSUDSMessageLogger* Logger,
bool bSilent);
bool ParseElseLine(const FStringView& Line,
ParsedTree& Tree,
int IndentLevel,
int LineNo,
const FString& NameForErrors,
FSUDSMessageLogger* Logger,
bool bSilent);
bool ParseEndIfLine(const FStringView& Line,
ParsedTree& Tree,
int IndentLevel,
int LineNo,
const FString& NameForErrors,
FSUDSMessageLogger*
Logger,
bool bSilent);
static bool IsRandomLine(const FStringView& Line);
bool ParseRandomLine(const FStringView& Line,
ParsedTree& Tree,
int IndentLevel,
int LineNo,
const FString& NameForErrors,
FSUDSMessageLogger* Logger,
bool bSilent);
bool ParseBeginRandomLine(const FStringView& Line,
ParsedTree& Tree,
int IndentLevel,
int LineNo,
const FString& NameForErrors,
FSUDSMessageLogger* Logger,
bool bSilent);
bool ParseRandomOptionLine(const FStringView& Line,
ParsedTree& Tree,
int IndentLevel,
int LineNo,
const FString& NameForErrors,
FSUDSMessageLogger* Logger,
bool bSilent);
bool ParseEndRandomLine(const FStringView& Line,
ParsedTree& Tree,
int IndentLevel,
int LineNo,
const FString& NameForErrors,
FSUDSMessageLogger* Logger,
bool bSilent);
bool ParseGotoLabelLine(const FStringView& Line,
ParsedTree& Tree,
int IndentLevel,
int LineNo,
const FString& NameForErrors,
FSUDSMessageLogger* Logger,
bool bSilent);
bool ParseGotoLine(const FStringView& Line,
ParsedTree& Tree,
int IndentLevel,
int LineNo,
const FString& NameForErrors,
FSUDSMessageLogger* Logger,
bool bSilent);
bool ParseGosubLine(const FStringView& Line,
ParsedTree& Tree,
int IndentLevel,
int LineNo,
const FString& NameForErrors,
FSUDSMessageLogger* Logger,
bool bSilent);
bool ParseReturnLine(const FStringView& Line,
ParsedTree& Tree,
int IndentLevel,
int LineNo,
const FString& NameForErrors,
FSUDSMessageLogger* Logger,
bool bSilent);
bool ParseSetLine(const FStringView& Line,
ParsedTree& Tree,
int IndentLevel,
int LineNo,
const FString& NameForErrors,
FSUDSMessageLogger* Logger,
bool bSilent);
bool ParseEventLine(const FStringView& Line,
ParsedTree& Tree,
int IndentLevel,
int LineNo,
const FString& NameForErrors,
FSUDSMessageLogger* Logger,
bool bSilent);
bool ParseTextLine(const FStringView& Line,
ParsedTree& Tree,
int IndentLevel,
int LineNo,
const FString& NameForErrors,
FSUDSMessageLogger* Logger,
bool bSilent);
bool ParseImportSettingLine(const FStringView& Line,
ParsedTree& Tree,
int IndentLevel,
int LineNo,
const FString& NameForErrors,
FSUDSMessageLogger* Logger,
bool bSilent);
TMap<FName, FString> GetTextMetadataForNextEntry(int CurrentLineIndent);
TMap<FName, FSUDSExpression> ConsumeUserMetadata();
bool IsCommentLine(const FStringView& TrimmedLine);
FStringView TrimLine(const FStringView& Line, int& OutIndentLevel) const;
int FindChoiceAfterTextNode(const FSUDSScriptImporter::ParsedTree& Tree, int TextNodeIdx, const FString& ConditionalPath);
int RecurseChoiceNodeFindDeepest(const ParsedTree& Tree, int FromChoiceIdx, const FString& ConditionalPath);
int RecurseSelectNodeFindDeepestChoice(const ParsedTree& Tree, int FromSelectNode, const FString& ConditionalPath);
int FindEdge(const FSUDSScriptImporter::ParsedTree& Tree, int ParentNodeIdx, int TargetNodeIndex);
FString MakeIfConditionPathElement(int SelectNodeIdx, const FString& ConditionStr);
FString MakeElseIfConditionPathElement(int SelectNodeIdx, const FString& ConditionStr);
FString MakeElseConditionPathElement(int SelectNodeIdx);
int FindLastChoiceNode(const ParsedTree& Tree, int IndentLevel);
int FindLastChoiceNode(const ParsedTree& Tree, int IndentLevel, int FromIndex, const FString& ConditionPath);
void PopIndent(ParsedTree& Tree);
void PushIndent(ParsedTree& Tree, int NodeIdx, int Indent, const FString& Path);
FString GetCurrentTreePath(const FSUDSScriptImporter::ParsedTree& Tree);
FString GetCurrentTreeConditionalPath(const FSUDSScriptImporter::ParsedTree& Tree);
FString GetTreeConditionalPath(const ParsedTree& Tree, int NodeIndex);
void SetFallthroughForNewNode(FSUDSScriptImporter::ParsedTree& Tree, FSUDSParsedNode& NewNode);
int AppendNode(ParsedTree& Tree, const FSUDSParsedNode& InNode);
bool SelectNodeIsMissingElsePath(const FSUDSScriptImporter::ParsedTree& Tree, const FSUDSParsedNode& Node);
bool PostImportSanityCheck(const FString& NameForErrors, FSUDSMessageLogger* Logger, bool bSilent);
bool ChoiceNodeCheckPaths(const FSUDSParsedNode& ChoiceNode,
const FString& NameForErrors,
FSUDSMessageLogger* Logger,
bool bSilent);
bool RecurseChoiceNodeCheckPaths(const FSUDSParsedNode& OrigChoiceNode, const FSUDSParsedNode& CurrChoiceNode, const FString& NameForErrors, FSUDSMessageLogger* Logger, bool bSilent);
bool RecurseChoiceNodeCheckPaths(const FSUDSParsedNode& ChoiceNode, const FSUDSParsedEdge& Edge, const FString& NameForErrors, FSUDSMessageLogger*
Logger,
bool bSilent);
void ConnectRemainingNodes(ParsedTree& Tree, const FString& NameForErrors, FSUDSMessageLogger* Logger, bool bSilent);
void GenerateTextIDs(ParsedTree& BodyTree);
int FindFallthroughNodeIndex(ParsedTree& Tree, int StartNodeIndex, const FString& FromChoicePath, const FString& FromConditionalPath);
bool RetrieveAndRemoveTextID(FStringView& InOutLine, FString& OutTextID);
bool RetrieveAndRemoveGosubID(FStringView& InOutLine, FString& OutTextID);
FString GenerateTextID();
const FSUDSParsedNode* GetNode(const ParsedTree& Tree, int Index = 0);
int GetGotoTargetNodeIndex(const ParsedTree& Tree, const FString& InLabel);
void PopulateAssetFromTree(USUDSScript* Asset,
const ParsedTree& Tree,
TArray<TObjectPtr<class USUDSScriptNode>>* pOutNodes,
TMap<FName, int>* pOutLabels,
UStringTable* StringTable);
public:
const FSUDSParsedNode* GetNode(int Index = 0);
const FSUDSParsedNode* GetHeaderNode(int Index = 0);
/// Resolve a goto label to a target index (after import), or -1 if not resolvable
int GetGotoTargetNodeIndex(const FString& Label);
static bool RetrieveTextIDFromLine(FStringView& InOutLine, FString& OutTextID, int& OutNumber);
static bool RetrieveGosubIDFromLine(FStringView& InOutLine, FString& OutID, int& OutNumber);
};

View File

@@ -0,0 +1,27 @@
// Copyright Steve Streeting 2022
// Released under the MIT license https://opensource.org/license/MIT/
#pragma once
#include "CoreMinimal.h"
#include "UObject/ObjectMacros.h"
#include "EditorReimportHandler.h"
#include "SUDSScriptFactory.h"
#include "SUDSScriptReimportFactory.generated.h"
// Reimports a USUDSScriptFactory asset
// Necessary to get the import pop-up
UCLASS()
class USUDSScriptReimportFactory : public USUDSScriptFactory, public FReimportHandler
{
GENERATED_BODY()
public:
USUDSScriptReimportFactory();
//~ Begin FReimportHandler Interface
virtual bool CanReimport(UObject* Obj, TArray<FString>& OutFilenames) override;
virtual void SetReimportPaths(UObject* Obj, const TArray<FString>& NewReimportPaths) override;
virtual EReimportResult::Type Reimport(UObject* Obj) override;
virtual int32 GetPriority() const override;
//~ End FReimportHandler Interface
};

View File

@@ -0,0 +1,62 @@
// Copyright Steve Streeting 2022
// Released under the MIT license https://opensource.org/license/MIT/
using UnrealBuildTool;
public class SUDSEditor : ModuleRules
{
public SUDSEditor(ReadOnlyTargetRules Target) : base(Target)
{
PCHUsage = ModuleRules.PCHUsageMode.UseExplicitOrSharedPCHs;
PublicIncludePaths.AddRange(
new string[] {
// ... add public include paths required here ...
}
);
PrivateIncludePaths.AddRange(
new string[] {
// ... add other private include paths required here ...
}
);
PublicDependencyModuleNames.AddRange(
new string[]
{
"Core",
"SUDS"
// ... add other public dependencies that you statically link with here ...
}
);
PrivateDependencyModuleNames.AddRange(
new string[]
{
"CoreUObject",
"Engine",
"Slate",
"SlateCore",
"InputCore", // needed by come Slate widgets
"Projects", // So that we can use the IPluginManager, required for our custom style
"ToolMenus",
"MessageLog",
"UnrealEd",
"EditorStyle"
// ... add private dependencies that you statically link with here ...
}
);
DynamicallyLoadedModuleNames.AddRange(
new string[]
{
// ... add any modules that your module loads dynamically here ...
}
);
}
}

View File

@@ -0,0 +1,21 @@
#include "SudsTestModule.h"
#include "Modules/ModuleManager.h"
DEFINE_LOG_CATEGORY(LogSudsTestModule)
void FSudsTestModule::StartupModule()
{
// This code will execute after your module is loaded into memory; the exact timing is specified in the .uplugin file per-module
UE_LOG(LogSudsTestModule, Log, TEXT("SUDSTest Module Started"))
}
void FSudsTestModule::ShutdownModule()
{
// This function may be called during shutdown to clean up your module. For modules that support dynamic reloading,
// we call this function before unloading the module.
UE_LOG(LogSudsTestModule, Log, TEXT("SUDSTest Module Stopped"))
}
IMPLEMENT_MODULE(FSudsTestModule, SUDSTest)

View File

@@ -0,0 +1,14 @@
#pragma once
#include "CoreMinimal.h"
#include "Modules/ModuleInterface.h"
DECLARE_LOG_CATEGORY_EXTERN(LogSudsTestModule, All, All)
class FSudsTestModule : public IModuleInterface
{
public:
virtual void StartupModule() override;
virtual void ShutdownModule() override;
};

View File

@@ -0,0 +1,274 @@
#include "SUDSDialogue.h"
#include "SUDSLibrary.h"
#include "SUDSMessageLogger.h"
#include "SUDSScript.h"
#include "SUDSScriptImporter.h"
#include "TestParticipant.h"
#include "TestUtils.h"
#include "Misc/AutomationTest.h"
UE_DISABLE_OPTIMIZATION
const FString ChoicesAsSpeakerLineEnableInScriptInput = R"RAWSUD(
===
[importsetting GenerateSpeakerLinesFromChoices true]
===
Player: Hello
* Choice 1
NPC: I see
* Choice 1a
NPC: Sure
*- Not a speaker line!
Player: I had to say this separately
* Choice 2
NPC: Totally
Player: The end
)RAWSUD";
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestChoiceSpeakerLineInScript,
"SUDSTest.TestChoiceSpeakerLineInScript",
EAutomationTestFlags::EditorContext |
EAutomationTestFlags::ClientContext |
EAutomationTestFlags::ProductFilter)
bool FTestChoiceSpeakerLineInScript::RunTest(const FString& Parameters)
{
FSUDSMessageLogger Logger(false);
FSUDSScriptImporter Importer;
TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(ChoicesAsSpeakerLineEnableInScriptInput), ChoicesAsSpeakerLineEnableInScriptInput.Len(), "ChoicesAsSpeakerLineEnableInScriptInput", &Logger, true));
auto Script = NewObject<USUDSScript>(GetTransientPackage(), "Test");
const ScopedStringTableHolder StringTableHolder;
Importer.PopulateAsset(Script, StringTableHolder.StringTable);
// Script shouldn't be the owner of the dialogue but it's the only UObject we've got right now so why not
auto Dlg = USUDSLibrary::CreateDialogue(Script, Script);
auto Participant = NewObject<UTestParticipant>();
Participant->TestNumber = 0;
Dlg->AddParticipant(Participant);
Dlg->Start();
TestDialogueText(this, "Line 1", Dlg, "Player", "Hello");
TestEqual("Choice text", Dlg->GetChoiceText(0).ToString(), TEXT("Choice 1"));
TestEqual("Choice text", Dlg->GetChoiceText(1).ToString(), TEXT("Choice 2"));
const FText ChoiceText0 = Dlg->GetChoiceText(0);
Dlg->Choose(0);
// Confirm that we got the choice as a speaker line
TestDialogueText(this, "Choice as speaker line 1", Dlg, "Player", "Choice 1");
// Confirm that text ID is the same
TestTrue("Text should be identical", ChoiceText0.IdenticalTo(Dlg->GetText(), ETextIdenticalModeFlags::DeepCompare));
TestEqual("TextIDs should match", SUDS_GET_TEXT_KEY(ChoiceText0), SUDS_GET_TEXT_KEY(Dlg->GetText()));
Dlg->Continue();
TestDialogueText(this, "Regular speaker line", Dlg, "NPC", "I see");
TestEqual("Choice text", Dlg->GetChoiceText(0).ToString(), TEXT("Choice 1a"));
TestEqual("Choice text", Dlg->GetChoiceText(1).ToString(), TEXT("Not a speaker line!"));
// Test choice which disables speaker line
Dlg->Choose(1);
TestDialogueText(this, "Regular speaker line", Dlg, "Player", "I had to say this separately");
Script->MarkAsGarbage();
return true;
}
const FString ChoicesAsSpeakerLineSetSpeakerIdInScriptInput = R"RAWSUD(
===
[importsetting GenerateSpeakerLinesFromChoices true]
[importsetting SpeakerIDForGeneratedLinesFromChoices `TheDude`]
===
Player: Hello
* Choice 1
NPC: You said that choice
* Choice 2
NPC: Also that one
Player: The end
)RAWSUD";
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestChoiceSpeakerSetSpeakerIdInScriptInput,
"SUDSTest.TestChoiceSpeakerSetSpeakerIdInScriptInput",
EAutomationTestFlags::EditorContext |
EAutomationTestFlags::ClientContext |
EAutomationTestFlags::ProductFilter)
bool FTestChoiceSpeakerSetSpeakerIdInScriptInput::RunTest(const FString& Parameters)
{
FSUDSMessageLogger Logger(false);
FSUDSScriptImporter Importer;
TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(ChoicesAsSpeakerLineSetSpeakerIdInScriptInput), ChoicesAsSpeakerLineSetSpeakerIdInScriptInput.Len(), "ChoicesAsSpeakerLineSetSpeakerIdInScriptInput", &Logger, true));
auto Script = NewObject<USUDSScript>(GetTransientPackage(), "Test");
const ScopedStringTableHolder StringTableHolder;
Importer.PopulateAsset(Script, StringTableHolder.StringTable);
// Script shouldn't be the owner of the dialogue but it's the only UObject we've got right now so why not
auto Dlg = USUDSLibrary::CreateDialogue(Script, Script);
auto Participant = NewObject<UTestParticipant>();
Participant->TestNumber = 0;
Dlg->AddParticipant(Participant);
Dlg->Start();
TestDialogueText(this, "Line 1", Dlg, "Player", "Hello");
TestEqual("Choice text", Dlg->GetChoiceText(0).ToString(), TEXT("Choice 1"));
TestEqual("Choice text", Dlg->GetChoiceText(1).ToString(), TEXT("Choice 2"));
const FText ChoiceText0 = Dlg->GetChoiceText(0);
Dlg->Choose(0);
// Confirm that text ID is the same
TestTrue("Text should be identical", ChoiceText0.IdenticalTo(Dlg->GetText(), ETextIdenticalModeFlags::DeepCompare));
TestEqual("TextIDs should match", SUDS_GET_TEXT_KEY(ChoiceText0), SUDS_GET_TEXT_KEY(Dlg->GetText()));
// Confirm that we got the choice as a speaker line, with custom speaker ID
TestDialogueText(this, "Choice as speaker line 1", Dlg, "TheDude", "Choice 1");
Dlg->Continue();
TestDialogueText(this, "Regular speaker line", Dlg, "NPC", "You said that choice");
Script->MarkAsGarbage();
return true;
}
const FString ChoicesAsSpeakerLineChainChoiceInput = R"RAWSUD(
===
[importsetting GenerateSpeakerLinesFromChoices true]
===
Player: Hello
* Choice 1
* Choice 1a
NPC: Hey this worked
* Choice 1b
* Choice 2
NPC: Also that one
Player: The end
)RAWSUD";
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestChoiceSpeakerChainedChoiceInput,
"SUDSTest.TestChoiceSpeakerChainedChoiceInput",
EAutomationTestFlags::EditorContext |
EAutomationTestFlags::ClientContext |
EAutomationTestFlags::ProductFilter)
bool FTestChoiceSpeakerChainedChoiceInput::RunTest(const FString& Parameters)
{
FSUDSMessageLogger Logger(false);
FSUDSScriptImporter Importer;
TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(ChoicesAsSpeakerLineChainChoiceInput), ChoicesAsSpeakerLineChainChoiceInput.Len(), "ChoicesAsSpeakerLineChainChoiceInput", &Logger, true));
auto Script = NewObject<USUDSScript>(GetTransientPackage(), "Test");
const ScopedStringTableHolder StringTableHolder;
Importer.PopulateAsset(Script, StringTableHolder.StringTable);
// Script shouldn't be the owner of the dialogue but it's the only UObject we've got right now so why not
auto Dlg = USUDSLibrary::CreateDialogue(Script, Script);
auto Participant = NewObject<UTestParticipant>();
Participant->TestNumber = 0;
Dlg->AddParticipant(Participant);
Dlg->Start();
TestDialogueText(this, "Line 1", Dlg, "Player", "Hello");
TestEqual("Choice text", Dlg->GetChoiceText(0).ToString(), TEXT("Choice 1"));
TestEqual("Choice text", Dlg->GetChoiceText(1).ToString(), TEXT("Choice 2"));
Dlg->Choose(0);
// Confirm that we got the choice as a speaker line, with custom speaker ID
TestDialogueText(this, "Choice as speaker line 1", Dlg, "Player", "Choice 1");
TestEqual("Choice 2nd level text", Dlg->GetChoiceText(0).ToString(), TEXT("Choice 1a"));
TestEqual("Choice 2nd leveltext", Dlg->GetChoiceText(1).ToString(), TEXT("Choice 1b"));
Dlg->Choose(0);
TestDialogueText(this, "Choice as speaker line 1", Dlg, "Player", "Choice 1a");
Dlg->Continue();
TestDialogueText(this, "Regular speaker line", Dlg, "NPC", "Hey this worked");
Script->MarkAsGarbage();
return true;
}
const FString ChoicesAsSpeakerLineWithStringKeysInput = R"RAWSUD(
===
[importsetting GenerateSpeakerLinesFromChoices true]
===
Player: Hello @0012@
* Choice 1 @0016@
NPC: I see @0017@
* Choice 1a @0018@
NPC: Sure
*- Not a speaker line!
Player: I had to say this separately
* Choice 2
NPC: Totally
Player: The end
)RAWSUD";
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestChoicesAsSpeakerLineWithStringKeys,
"SUDSTest.TestChoicesAsSpeakerLineWithStringKeys",
EAutomationTestFlags::EditorContext |
EAutomationTestFlags::ClientContext |
EAutomationTestFlags::ProductFilter)
bool FTestChoicesAsSpeakerLineWithStringKeys::RunTest(const FString& Parameters)
{
FSUDSMessageLogger Logger(false);
FSUDSScriptImporter Importer;
TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(ChoicesAsSpeakerLineWithStringKeysInput), ChoicesAsSpeakerLineWithStringKeysInput.Len(), "ChoicesAsSpeakerLineWithStringKeysInput", &Logger, true));
auto Script = NewObject<USUDSScript>(GetTransientPackage(), "Test");
const ScopedStringTableHolder StringTableHolder;
Importer.PopulateAsset(Script, StringTableHolder.StringTable);
// Script shouldn't be the owner of the dialogue but it's the only UObject we've got right now so why not
auto Dlg = USUDSLibrary::CreateDialogue(Script, Script);
auto Participant = NewObject<UTestParticipant>();
Participant->TestNumber = 0;
Dlg->AddParticipant(Participant);
Dlg->Start();
TestDialogueText(this, "Line 1", Dlg, "Player", "Hello");
TestEqual("String key", SUDS_GET_TEXT_KEY(Dlg->GetText()), "@0012@");
TestEqual("Choice text", Dlg->GetChoiceText(0).ToString(), TEXT("Choice 1"));
TestEqual("Choice text", Dlg->GetChoiceText(1).ToString(), TEXT("Choice 2"));
TestEqual("String key", SUDS_GET_TEXT_KEY(Dlg->GetChoiceText(0)), "@0016@");
FText ChoiceText0 = Dlg->GetChoiceText(0);
Dlg->Choose(0);
// Confirm that we got the choice as a speaker line
TestDialogueText(this, "Choice as speaker line 1", Dlg, "Player", "Choice 1");
// Confirm that text ID is the same
TestTrue("Text should be identical", ChoiceText0.IdenticalTo(Dlg->GetText(), ETextIdenticalModeFlags::DeepCompare));
TestEqual("TextIDs should match", SUDS_GET_TEXT_KEY(ChoiceText0), SUDS_GET_TEXT_KEY(Dlg->GetText()));
Dlg->Continue();
TestDialogueText(this, "Regular speaker line", Dlg, "NPC", "I see");
TestEqual("String key", SUDS_GET_TEXT_KEY(Dlg->GetText()), "@0017@");
TestEqual("String key", SUDS_GET_TEXT_KEY(Dlg->GetChoiceText(0)), "@0018@");
TestEqual("Choice text", Dlg->GetChoiceText(0).ToString(), TEXT("Choice 1a"));
TestEqual("Choice text", Dlg->GetChoiceText(1).ToString(), TEXT("Not a speaker line!"));
ChoiceText0 = Dlg->GetChoiceText(0);
Dlg->Choose(0);
TestDialogueText(this, "Duplicated speaker line", Dlg, "Player", "Choice 1a");
// Confirm that text ID is the same
TestTrue("Text should be identical", ChoiceText0.IdenticalTo(Dlg->GetText(), ETextIdenticalModeFlags::DeepCompare));
TestEqual("TextIDs should match", SUDS_GET_TEXT_KEY(ChoiceText0), SUDS_GET_TEXT_KEY(Dlg->GetText()));
Script->MarkAsGarbage();
return true;
}
UE_ENABLE_OPTIMIZATION

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,20 @@
#include "TestEventSub.h"
#include "SUDSDialogue.h"
void UTestEventSub::Init(USUDSDialogue* Dlg)
{
Dlg->OnEvent.AddDynamic(this, &UTestEventSub::OnEvent);
Dlg->OnVariableChanged.AddDynamic(this, &UTestEventSub::OnVariableChanged);
}
void UTestEventSub::OnEvent(USUDSDialogue* Dlg, FName EventName, const TArray<FSUDSValue>& Args)
{
EventRecords.Add(FEventRecord { EventName, Args });
}
void UTestEventSub::OnVariableChanged(USUDSDialogue* Dlg, FName VarName, const FSUDSValue& Value, bool bFromScript)
{
SetVarRecords.Add(FSetVarRecord { VarName, Value, bFromScript });
}

View File

@@ -0,0 +1,39 @@
#pragma once
#include "CoreMinimal.h"
#include "SUDSValue.h"
#include "UObject/Object.h"
#include "TestEventSub.generated.h"
class USUDSDialogue;
UCLASS()
class SUDSTEST_API UTestEventSub : public UObject
{
GENERATED_BODY()
public:
void Init(USUDSDialogue* Dlg);
struct FEventRecord
{
FName Name;
TArray<FSUDSValue> Args;
};
struct FSetVarRecord
{
FName Name;
FSUDSValue Value;
bool bFromScript;
};
TArray<FEventRecord> EventRecords;
TArray<FSetVarRecord> SetVarRecords;
UFUNCTION()
void OnEvent(USUDSDialogue* Dlg, FName EventName, const TArray<FSUDSValue>& Args);
UFUNCTION()
void OnVariableChanged(USUDSDialogue* Dlg, FName VarName, const FSUDSValue& Value, bool bFromScript);
};

View File

@@ -0,0 +1,278 @@
#include "SUDSDialogue.h"
#include "SUDSLibrary.h"
#include "SUDSMessageLogger.h"
#include "SUDSScript.h"
#include "SUDSScriptImporter.h"
#include "TestEventSub.h"
#include "TestParticipant.h"
#include "TestUtils.h"
UE_DISABLE_OPTIMIZATION
const FString EventParsingInput = R"RAWSUD(
Player: Ow do?
[set IntVar 2]
[set FloatVar 66.67]
[set StringVar "Ey up"]
[event SummatHappened "2 penneth", 99.99, masculine, {IntVar}, 42, false]
NPC: Alreet chook
[event Well.Blow.Me.Down {FloatVar}, true, {StringVar}]
[event Calculated {FloatVar} + 10, true or false, {StringVar}]
[event Actor.Emote `Player`, `Question`]
Player: Tara
)RAWSUD";
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestEvents,
"SUDSTest.TestEvents",
EAutomationTestFlags::EditorContext |
EAutomationTestFlags::ClientContext |
EAutomationTestFlags::ProductFilter)
bool FTestEvents::RunTest(const FString& Parameters)
{
FSUDSMessageLogger Logger(false);
FSUDSScriptImporter Importer;
TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(EventParsingInput), EventParsingInput.Len(), "EventParsingInput", &Logger, true));
auto Script = NewObject<USUDSScript>(GetTransientPackage(), "Test");
const ScopedStringTableHolder StringTableHolder;
Importer.PopulateAsset(Script, StringTableHolder.StringTable);
// Script shouldn't be the owner of the dialogue but it's the only UObject we've got right now so why not
auto Dlg = USUDSLibrary::CreateDialogue(Script, Script);
// Subscriber
auto EvtSub = NewObject<UTestEventSub>();
EvtSub->Init(Dlg);
// Participant
auto Participant = NewObject<UTestParticipant>();
Participant->TestNumber = 0;
Dlg->AddParticipant(Participant);
Dlg->Start();
TestDialogueText(this, "Line 1", Dlg, "Player", "Ow do?");
TestTrue("Continue", Dlg->Continue());
// Should have had an event
if (TestEqual("Event sub should have received", EvtSub->EventRecords.Num(), 1))
{
TestEqual("Event name", EvtSub->EventRecords[0].Name.ToString(), "SummatHappened");
if (TestEqual("Event sub arg count", EvtSub->EventRecords[0].Args.Num(), 6))
{
TestEqual("Event sub arg 0 type", EvtSub->EventRecords[0].Args[0].GetType(), ESUDSValueType::Text);
TestEqual("Event sub arg 0 value", EvtSub->EventRecords[0].Args[0].GetTextValue().ToString(), "2 penneth");
TestEqual("Event sub arg 1 type", EvtSub->EventRecords[0].Args[1].GetType(), ESUDSValueType::Float);
TestEqual("Event sub arg 1 value", EvtSub->EventRecords[0].Args[1].GetFloatValue(), 99.99f);
TestEqual("Event sub arg 2 type", EvtSub->EventRecords[0].Args[2].GetType(), ESUDSValueType::Gender);
TestEqual("Event sub arg 2 value", EvtSub->EventRecords[0].Args[2].GetGenderValue(), ETextGender::Masculine);
TestEqual("Event sub arg 3 type", EvtSub->EventRecords[0].Args[3].GetType(), ESUDSValueType::Int);
TestEqual("Event sub arg 3 value", EvtSub->EventRecords[0].Args[3].GetIntValue(), 2);
TestEqual("Event sub arg 4 type", EvtSub->EventRecords[0].Args[4].GetType(), ESUDSValueType::Int);
TestEqual("Event sub arg 4 value", EvtSub->EventRecords[0].Args[4].GetIntValue(), 42);
TestEqual("Event sub arg 5 type", EvtSub->EventRecords[0].Args[5].GetType(), ESUDSValueType::Boolean);
TestEqual("Event sub arg 5 value", EvtSub->EventRecords[0].Args[5].GetBooleanValue(), false);
}
}
if (TestEqual("Participant should have received", Participant->EventRecords.Num(), 1))
{
TestEqual("Event name", Participant->EventRecords[0].Name.ToString(), "SummatHappened");
if (TestEqual("Participant arg count", Participant->EventRecords[0].Args.Num(), 6))
{
TestEqual("Participant arg 0 type", Participant->EventRecords[0].Args[0].GetType(), ESUDSValueType::Text);
TestEqual("Participant arg 0 value", Participant->EventRecords[0].Args[0].GetTextValue().ToString(), "2 penneth");
TestEqual("Participant arg 1 type", Participant->EventRecords[0].Args[1].GetType(), ESUDSValueType::Float);
TestEqual("Participant arg 1 value", Participant->EventRecords[0].Args[1].GetFloatValue(), 99.99f);
TestEqual("Participant arg 2 type", Participant->EventRecords[0].Args[2].GetType(), ESUDSValueType::Gender);
TestEqual("Participant arg 2 value", Participant->EventRecords[0].Args[2].GetGenderValue(), ETextGender::Masculine);
TestEqual("Participant arg 3 type", Participant->EventRecords[0].Args[3].GetType(), ESUDSValueType::Int);
TestEqual("Participant arg 3 value", Participant->EventRecords[0].Args[3].GetIntValue(), 2);
TestEqual("Participant arg 4 type", Participant->EventRecords[0].Args[4].GetType(), ESUDSValueType::Int);
TestEqual("Participant arg 4 value", Participant->EventRecords[0].Args[4].GetIntValue(), 42);
TestEqual("Participant arg 5 type", Participant->EventRecords[0].Args[5].GetType(), ESUDSValueType::Boolean);
TestEqual("Participant arg 5 value", Participant->EventRecords[0].Args[5].GetBooleanValue(), false);
}
}
if (TestEqual("Event var sub should have received", EvtSub->SetVarRecords.Num(), 10))
{
// First 7 are from the participant setting variables at startup
TestEqual("Event var sub arg 0 name", EvtSub->SetVarRecords[0].Name.ToString(), "SpeakerName.Player");
TestEqual("Event var sub arg 0 type", EvtSub->SetVarRecords[0].Value.GetType(), ESUDSValueType::Text);
TestEqual("Event var sub arg 0 value", EvtSub->SetVarRecords[0].Value.GetTextValue().ToString(), "Protagonist");
TestFalse("Event var sub arg 0 fromscript", EvtSub->SetVarRecords[0].bFromScript);
TestEqual("Event var sub arg 1 name", EvtSub->SetVarRecords[1].Name.ToString(), "SpeakerName.NPC");
TestEqual("Event var sub arg 1 type", EvtSub->SetVarRecords[1].Value.GetType(), ESUDSValueType::Text);
TestEqual("Event var sub arg 1 value", EvtSub->SetVarRecords[1].Value.GetTextValue().ToString(), "An NPC");
TestFalse("Event var sub arg 1 fromscript", EvtSub->SetVarRecords[1].bFromScript);
TestEqual("Event var sub arg 2 name", EvtSub->SetVarRecords[2].Name.ToString(), "NumCats");
TestEqual("Event var sub arg 2 type", EvtSub->SetVarRecords[2].Value.GetType(), ESUDSValueType::Int);
TestEqual("Event var sub arg 2 value", EvtSub->SetVarRecords[2].Value.GetIntValue(), 3);
TestFalse("Event var sub arg 2 fromscript", EvtSub->SetVarRecords[2].bFromScript);
TestEqual("Event var sub arg 3 name", EvtSub->SetVarRecords[3].Name.ToString(), "FriendName");
TestEqual("Event var sub arg 3 type", EvtSub->SetVarRecords[3].Value.GetType(), ESUDSValueType::Text);
TestEqual("Event var sub arg 3 value", EvtSub->SetVarRecords[3].Value.GetTextValue().ToString(), "Susan");
TestFalse("Event var sub arg 3 fromscript", EvtSub->SetVarRecords[3].bFromScript);
TestEqual("Event var sub arg 4 name", EvtSub->SetVarRecords[4].Name.ToString(), "Gender");
TestEqual("Event var sub arg 4 type", EvtSub->SetVarRecords[4].Value.GetType(), ESUDSValueType::Gender);
TestEqual("Event var sub arg 4 value", EvtSub->SetVarRecords[4].Value.GetGenderValue(), ETextGender::Feminine);
TestFalse("Event var sub arg 4 fromscript", EvtSub->SetVarRecords[4].bFromScript);
TestEqual("Event var sub arg 5 name", EvtSub->SetVarRecords[5].Name.ToString(), "FloatVal");
TestEqual("Event var sub arg 5 type", EvtSub->SetVarRecords[5].Value.GetType(), ESUDSValueType::Float);
TestEqual("Event var sub arg 5 value", EvtSub->SetVarRecords[5].Value.GetFloatValue(), 12.567f);
TestFalse("Event var sub arg 5 fromscript", EvtSub->SetVarRecords[5].bFromScript);
TestEqual("Event var sub arg 6 name", EvtSub->SetVarRecords[6].Name.ToString(), "BoolVal");
TestEqual("Event var sub arg 6 type", EvtSub->SetVarRecords[6].Value.GetType(), ESUDSValueType::Boolean);
TestEqual("Event var sub arg 6 value", EvtSub->SetVarRecords[6].Value.GetBooleanValue(), true);
TestFalse("Event var sub arg 6 fromscript", EvtSub->SetVarRecords[6].bFromScript);
// Next 3 are from script
TestEqual("Event var sub arg 7 name", EvtSub->SetVarRecords[7].Name.ToString(), "IntVar");
TestEqual("Event var sub arg 7 type", EvtSub->SetVarRecords[7].Value.GetType(), ESUDSValueType::Int);
TestEqual("Event var sub arg 7 value", EvtSub->SetVarRecords[7].Value.GetIntValue(), 2);
TestTrue("Event var sub arg 7 fromscript", EvtSub->SetVarRecords[7].bFromScript);
TestEqual("Event var sub arg 8 name", EvtSub->SetVarRecords[8].Name.ToString(), "FloatVar");
TestEqual("Event var sub arg 8 type", EvtSub->SetVarRecords[8].Value.GetType(), ESUDSValueType::Float);
TestEqual("Event var sub arg 8 value", EvtSub->SetVarRecords[8].Value.GetFloatValue(), 66.67f);
TestTrue("Event var sub arg 8 fromscript", EvtSub->SetVarRecords[8].bFromScript);
TestEqual("Event var sub arg 9 name", EvtSub->SetVarRecords[9].Name.ToString(), "StringVar");
TestEqual("Event var sub arg 9 type", EvtSub->SetVarRecords[9].Value.GetType(), ESUDSValueType::Text);
TestEqual("Event var sub arg 9 value", EvtSub->SetVarRecords[9].Value.GetTextValue().ToString(), "Ey up");
TestTrue("Event var sub arg 9 fromscript", EvtSub->SetVarRecords[9].bFromScript);
}
if (TestEqual("Participant var should have received", Participant->SetVarRecords.Num(), 10))
{
// First 7 are from the participant setting variables at startup
TestEqual("Participant var arg 0 name", Participant->SetVarRecords[0].Name.ToString(), "SpeakerName.Player");
TestEqual("Participant var arg 0 type", Participant->SetVarRecords[0].Value.GetType(), ESUDSValueType::Text);
TestEqual("Participant var arg 0 value", Participant->SetVarRecords[0].Value.GetTextValue().ToString(), "Protagonist");
TestFalse("Participant var arg 0 fromscript", Participant->SetVarRecords[0].bFromScript);
TestEqual("Participant var arg 1 name", Participant->SetVarRecords[1].Name.ToString(), "SpeakerName.NPC");
TestEqual("Participant var arg 1 type", Participant->SetVarRecords[1].Value.GetType(), ESUDSValueType::Text);
TestEqual("Participant var arg 1 value", Participant->SetVarRecords[1].Value.GetTextValue().ToString(), "An NPC");
TestFalse("Participant var arg 1 fromscript", Participant->SetVarRecords[1].bFromScript);
TestEqual("Participant var arg 2 name", Participant->SetVarRecords[2].Name.ToString(), "NumCats");
TestEqual("Participant var arg 2 type", Participant->SetVarRecords[2].Value.GetType(), ESUDSValueType::Int);
TestEqual("Participant var arg 2 value", Participant->SetVarRecords[2].Value.GetIntValue(), 3);
TestFalse("Participant var arg 2 fromscript", Participant->SetVarRecords[2].bFromScript);
TestEqual("Participant var arg 3 name", Participant->SetVarRecords[3].Name.ToString(), "FriendName");
TestEqual("Participant var arg 3 type", Participant->SetVarRecords[3].Value.GetType(), ESUDSValueType::Text);
TestEqual("Participant var arg 3 value", Participant->SetVarRecords[3].Value.GetTextValue().ToString(), "Susan");
TestFalse("Participant var arg 3 fromscript", Participant->SetVarRecords[3].bFromScript);
TestEqual("Participant var arg 4 name", Participant->SetVarRecords[4].Name.ToString(), "Gender");
TestEqual("Participant var arg 4 type", Participant->SetVarRecords[4].Value.GetType(), ESUDSValueType::Gender);
TestEqual("Participant var arg 4 value", Participant->SetVarRecords[4].Value.GetGenderValue(), ETextGender::Feminine);
TestFalse("Participant var arg 4 fromscript", Participant->SetVarRecords[4].bFromScript);
TestEqual("Participant var arg 5 name", Participant->SetVarRecords[5].Name.ToString(), "FloatVal");
TestEqual("Participant var arg 5 type", Participant->SetVarRecords[5].Value.GetType(), ESUDSValueType::Float);
TestEqual("Participant var arg 5 value", Participant->SetVarRecords[5].Value.GetFloatValue(), 12.567f);
TestFalse("Participant var arg 5 fromscript", Participant->SetVarRecords[5].bFromScript);
TestEqual("Participant var arg 6 name", Participant->SetVarRecords[6].Name.ToString(), "BoolVal");
TestEqual("Participant var arg 6 type", Participant->SetVarRecords[6].Value.GetType(), ESUDSValueType::Boolean);
TestEqual("Participant var arg 6 value", Participant->SetVarRecords[6].Value.GetBooleanValue(), true);
TestFalse("Participant var arg 6 fromscript", Participant->SetVarRecords[6].bFromScript);
// Next 3 are from script
TestEqual("Participant var arg 7 name", Participant->SetVarRecords[7].Name.ToString(), "IntVar");
TestEqual("Participant var arg 7 type", Participant->SetVarRecords[7].Value.GetType(), ESUDSValueType::Int);
TestEqual("Participant var arg 7 value", Participant->SetVarRecords[7].Value.GetIntValue(), 2);
TestTrue("Participant var arg 7 fromscript", Participant->SetVarRecords[7].bFromScript);
TestEqual("Participant var arg 8 name", Participant->SetVarRecords[8].Name.ToString(), "FloatVar");
TestEqual("Participant var arg 8 type", Participant->SetVarRecords[8].Value.GetType(), ESUDSValueType::Float);
TestEqual("Participant var arg 8 value", Participant->SetVarRecords[8].Value.GetFloatValue(), 66.67f);
TestTrue("Participant var arg 8 fromscript", Participant->SetVarRecords[8].bFromScript);
TestEqual("Participant var arg 9 name", Participant->SetVarRecords[9].Name.ToString(), "StringVar");
TestEqual("Participant var arg 9 type", Participant->SetVarRecords[9].Value.GetType(), ESUDSValueType::Text);
TestEqual("Participant var arg 9 value", Participant->SetVarRecords[9].Value.GetTextValue().ToString(), "Ey up");
TestTrue("Participant var arg 9 fromscript", Participant->SetVarRecords[9].bFromScript);
}
TestDialogueText(this, "Line 2", Dlg, "NPC", "Alreet chook");
TestTrue("Continue", Dlg->Continue());
if (TestEqual("Event sub should have received", EvtSub->EventRecords.Num(), 4))
{
TestEqual("Event name", EvtSub->EventRecords[1].Name.ToString(), "Well.Blow.Me.Down");
if (TestEqual("Event sub arg count", EvtSub->EventRecords[1].Args.Num(), 3))
{
TestEqual("Event sub arg 0 type", EvtSub->EventRecords[1].Args[0].GetType(), ESUDSValueType::Float);
TestEqual("Event sub arg 0 value", EvtSub->EventRecords[1].Args[0].GetFloatValue(), 66.67f);
TestEqual("Event sub arg 1 type", EvtSub->EventRecords[1].Args[1].GetType(), ESUDSValueType::Boolean);
TestEqual("Event sub arg 1 value", EvtSub->EventRecords[1].Args[1].GetBooleanValue(), true);
TestEqual("Event sub arg 2 type", EvtSub->EventRecords[1].Args[2].GetType(), ESUDSValueType::Text);
TestEqual("Event sub arg 2 value", EvtSub->EventRecords[1].Args[2].GetTextValue().ToString(), "Ey up");
}
TestEqual("Event name", EvtSub->EventRecords[2].Name.ToString(), "Calculated");
if (TestEqual("Event sub arg count", EvtSub->EventRecords[2].Args.Num(), 3))
{
TestEqual("Event sub arg 0 type", EvtSub->EventRecords[2].Args[0].GetType(), ESUDSValueType::Float);
TestEqual("Event sub arg 0 value", EvtSub->EventRecords[2].Args[0].GetFloatValue(), 76.67f);
TestEqual("Event sub arg 2 type", EvtSub->EventRecords[2].Args[1].GetType(), ESUDSValueType::Boolean);
TestEqual("Event sub arg 2 value", EvtSub->EventRecords[2].Args[1].GetBooleanValue(), true);
TestEqual("Event sub arg 2 type", EvtSub->EventRecords[2].Args[2].GetType(), ESUDSValueType::Text);
TestEqual("Event sub arg 2 value", EvtSub->EventRecords[2].Args[2].GetTextValue().ToString(), "Ey up");
}
TestEqual("Event name", EvtSub->EventRecords[3].Name.ToString(), "Actor.Emote");
if (TestEqual("Event sub arg count", EvtSub->EventRecords[3].Args.Num(), 2))
{
TestEqual("Event sub arg 0 type", EvtSub->EventRecords[3].Args[0].GetType(), ESUDSValueType::Name);
TestEqual("Event sub arg 0 value", EvtSub->EventRecords[3].Args[0].GetNameValue(), FName("Player"));
TestEqual("Event sub arg 2 type", EvtSub->EventRecords[3].Args[1].GetType(), ESUDSValueType::Name);
TestEqual("Event sub arg 2 value", EvtSub->EventRecords[3].Args[1].GetNameValue(), FName("Question"));
}
}
if (TestEqual("Participant should have received", Participant->EventRecords.Num(), 4))
{
TestEqual("Event name", Participant->EventRecords[1].Name.ToString(), "Well.Blow.Me.Down");
if (TestEqual("Participant arg count", Participant->EventRecords[1].Args.Num(), 3))
{
TestEqual("Participant arg 0 type", Participant->EventRecords[1].Args[0].GetType(), ESUDSValueType::Float);
TestEqual("Participant arg 0 value", Participant->EventRecords[1].Args[0].GetFloatValue(), 66.67f);
TestEqual("Participant arg 1 type", Participant->EventRecords[1].Args[1].GetType(), ESUDSValueType::Boolean);
TestEqual("Participant arg 1 value", Participant->EventRecords[1].Args[1].GetBooleanValue(), true);
TestEqual("Participant arg 2 type", Participant->EventRecords[1].Args[2].GetType(), ESUDSValueType::Text);
TestEqual("Participant arg 2 value", Participant->EventRecords[1].Args[2].GetTextValue().ToString(), "Ey up");
}
TestEqual("Event name", Participant->EventRecords[2].Name.ToString(), "Calculated");
if (TestEqual("Event sub arg count", Participant->EventRecords[2].Args.Num(), 3))
{
TestEqual("Event sub arg 0 type", Participant->EventRecords[2].Args[0].GetType(), ESUDSValueType::Float);
TestEqual("Event sub arg 0 value", Participant->EventRecords[2].Args[0].GetFloatValue(), 76.67f);
TestEqual("Event sub arg 2 type", Participant->EventRecords[2].Args[1].GetType(), ESUDSValueType::Boolean);
TestEqual("Event sub arg 2 value", Participant->EventRecords[2].Args[1].GetBooleanValue(), true);
TestEqual("Event sub arg 2 type", Participant->EventRecords[2].Args[2].GetType(), ESUDSValueType::Text);
TestEqual("Event sub arg 2 value", Participant->EventRecords[2].Args[2].GetTextValue().ToString(), "Ey up");
}
TestEqual("Event name", EvtSub->EventRecords[3].Name.ToString(), "Actor.Emote");
if (TestEqual("Event sub arg count", EvtSub->EventRecords[3].Args.Num(), 2))
{
TestEqual("Event sub arg 0 type", EvtSub->EventRecords[3].Args[0].GetType(), ESUDSValueType::Name);
TestEqual("Event sub arg 0 value", EvtSub->EventRecords[3].Args[0].GetNameValue(), FName("Player"));
TestEqual("Event sub arg 2 type", EvtSub->EventRecords[3].Args[1].GetType(), ESUDSValueType::Name);
TestEqual("Event sub arg 2 value", EvtSub->EventRecords[3].Args[1].GetNameValue(), FName("Question"));
}
}
Script->MarkAsGarbage();
return true;
}
UE_ENABLE_OPTIMIZATION

View File

@@ -0,0 +1,209 @@
#include "SUDSExpression.h"
#include "Misc/AutomationTest.h"
UE_DISABLE_OPTIMIZATION
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestExpressions,
"SUDSTest.TestExpressions",
EAutomationTestFlags::EditorContext |
EAutomationTestFlags::ClientContext |
EAutomationTestFlags::ProductFilter)
bool FTestExpressions::RunTest(const FString& Parameters)
{
FSUDSExpression Expr;
TMap<FName, FSUDSValue> Variables;
TMap<FName, FSUDSValue> GlobalVariables;
Variables.Add("Six", 6);
TestTrue("SimpleVarParse", Expr.ParseFromString("3 + 4 * {Six} + 1", nullptr));
TestEqual("Eval", Expr.Evaluate(Variables, GlobalVariables).GetIntValue(), 28);
auto& RPN = Expr.GetQueue();
if (TestEqual("Queue len", RPN.Num(), 7))
{
// RPN should be 3 4 6 * + 1 +
TestEqual("Queue 0", RPN[0].GetOperandValue().GetIntValue(), 3);
TestEqual("Queue 1", RPN[1].GetOperandValue().GetIntValue(), 4);
TestEqual("Queue 2", RPN[2].GetOperandValue().GetVariableNameValue().ToString(), "Six");
TestEqual("Queue 3", RPN[3].GetType(), ESUDSExpressionItemType::Multiply);
TestEqual("Queue 4", RPN[4].GetType(), ESUDSExpressionItemType::Add);
TestEqual("Queue 5", RPN[5].GetOperandValue().GetIntValue(), 1);
TestEqual("Queue 6", RPN[6].GetType(), ESUDSExpressionItemType::Add);
}
if (TestEqual("Variable count", Expr.GetVariableNames().Num(), 1))
{
TestEqual("Variable name", Expr.GetVariableNames()[0].ToString(), "Six");
}
TestTrue("Arithmetic", Expr.ParseFromString("-6.7 * 2 + (21.3 - 8) * 5", nullptr));
TestEqual("Eval", Expr.Evaluate(Variables, GlobalVariables).GetFloatValue(), 53.1f);
// Modulo operator
TestTrue("ModuloIntOperator", Expr.ParseFromString("11 % 5", nullptr));
TestEqual("Eval", Expr.Evaluate(Variables, GlobalVariables).GetIntValue(), 1);
TestTrue("ModuloFloatOperator", Expr.ParseFromString("7.25 % 3.0", nullptr));
TestEqual("Eval", Expr.Evaluate(Variables, GlobalVariables).GetFloatValue(), 1.25f);
// Explicit FSUDSValue(true) needed to avoid it using the int conversion by default
Variables.Add("IsATest", FSUDSValue(true));
TestTrue("BoolSingleValueParse", Expr.ParseFromString("{IsATest}", nullptr));
TestTrue("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue());
if (TestEqual("Variable count", Expr.GetVariableNames().Num(), 1))
{
TestEqual("Variable name", Expr.GetVariableNames()[0].ToString(), "IsATest");
}
Variables.Add("SomethingFalse", FSUDSValue(false));
Variables.Add("SomethingTrue", FSUDSValue(true));
Variables.Add("SomethingElseFalse", FSUDSValue(false));
TestTrue("BoolCompound1", Expr.ParseFromString("!{SomethingFalse} && {SomethingTrue}", nullptr));
TestTrue("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue());
if (TestEqual("Variable count", Expr.GetVariableNames().Num(), 2))
{
TestEqual("Variable name", Expr.GetVariableNames()[0].ToString(), "SomethingFalse");
TestEqual("Variable name", Expr.GetVariableNames()[1].ToString(), "SomethingTrue");
}
TestTrue("BoolCompound2", Expr.ParseFromString("{SomethingFalse} || {SomethingTrue}", nullptr));
TestTrue("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue());
TestTrue("BoolCompound3", Expr.ParseFromString("{SomethingFalse} or {SomethingTrue}", nullptr));
TestTrue("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue());
// Test parentheses changing precedence & result
// True result for successful parsing, but false for Eval unless we parenthesise
TestTrue("BoolCompound4", Expr.ParseFromString("!{SomethingFalse} && {SomethingElseFalse} && {SomethingTrue}", nullptr));
TestFalse("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue());
if (TestEqual("Variable count", Expr.GetVariableNames().Num(), 3))
{
TestEqual("Variable name", Expr.GetVariableNames()[0].ToString(), "SomethingFalse");
TestEqual("Variable name", Expr.GetVariableNames()[1].ToString(), "SomethingElseFalse");
TestEqual("Variable name", Expr.GetVariableNames()[2].ToString(), "SomethingTrue");
}
TestTrue("BoolCompound5", Expr.ParseFromString("!({SomethingFalse} && {SomethingElseFalse}) && {SomethingTrue}", nullptr));
TestTrue("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue());
TestTrue("BoolCompound6", Expr.ParseFromString("not {SomethingFalse} and {SomethingElseFalse} and {SomethingTrue}", nullptr));
TestFalse("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue());
TestTrue("BoolCompound7", Expr.ParseFromString("not ({SomethingFalse} and {SomethingElseFalse}) and {SomethingTrue}", nullptr));
TestTrue("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue());
Variables.Add("Seven", 7);
TestTrue("Comparisons", Expr.ParseFromString("{Six} == 6", nullptr));
TestTrue("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue());
TestTrue("Comparisons", Expr.ParseFromString("{Six} = 6", nullptr));
TestTrue("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue());
TestTrue("Comparisons", Expr.ParseFromString("{Six} >= 6", nullptr));
TestTrue("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue());
TestTrue("Comparisons", Expr.ParseFromString("{Six} > 6", nullptr));
TestFalse("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue());
TestTrue("Comparisons", Expr.ParseFromString("{Six} < 6", nullptr));
TestFalse("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue());
TestTrue("Comparisons", Expr.ParseFromString("{Six} <= 6", nullptr));
TestTrue("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue());
TestTrue("Comparisons", Expr.ParseFromString("{Six} < {Seven}", nullptr));
TestTrue("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue());
TestTrue("Comparisons", Expr.ParseFromString("{Seven} > {Six}", nullptr));
TestTrue("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue());
TestTrue("Comparisons", Expr.ParseFromString("{Seven} != {Six}", nullptr));
TestTrue("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue());
TestTrue("Comparisons", Expr.ParseFromString("{Seven} != {Seven}", nullptr));
TestFalse("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue());
// Mixed float/int comparisons
Variables.Add("EightFloat", 8.1f);
TestTrue("Comparisons", Expr.ParseFromString("{EightFloat} > 8", nullptr));
TestTrue("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue());
TestTrue("Comparisons", Expr.ParseFromString("{EightFloat} > {Seven}", nullptr));
TestTrue("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue());
TestTrue("Comparisons", Expr.ParseFromString("{Six} < {EightFloat}", nullptr));
TestTrue("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue());
// Fuzzy float comparisons
Variables.Add("EightFloatPlusMargin", 8.1000002f);
TestTrue("Comparisons", Expr.ParseFromString("{EightFloat} == 8.1", nullptr));
TestTrue("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue());
TestTrue("Comparisons", Expr.ParseFromString("{EightFloat} == 8.15", nullptr));
TestFalse("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue());
TestTrue("Comparisons", Expr.ParseFromString("{EightFloatPlusMargin} == 8.1", nullptr));
TestTrue("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue());
TestTrue("Comparisons", Expr.ParseFromString("{EightFloat} == 8.1000005", nullptr));
TestTrue("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue());
TestTrue("Comparisons", Expr.ParseFromString("{EightFloatPlusMargin} == 8.1000005", nullptr));
TestTrue("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue());
// Other type comparisons
Variables.Add("SomeText", FText::FromString("Hello"));
TestTrue("Comparisons", Expr.ParseFromString("{SomeText} == \"Hello\"", nullptr));
TestTrue("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue());
TestTrue("Comparisons", Expr.ParseFromString("{SomeText} == \"Hi\"", nullptr));
TestFalse("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue());
Variables.Add("Male", ETextGender::Masculine);
Variables.Add("Female", ETextGender::Feminine);
Variables.Add("AlsoFemale", ETextGender::Feminine);
Variables.Add("Neuter", ETextGender::Neuter);
TestTrue("Comparisons", Expr.ParseFromString("{Male} == masculine", nullptr));
TestTrue("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue());
TestTrue("Comparisons", Expr.ParseFromString("{Male} == Masculine", nullptr));
TestTrue("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue());
TestTrue("Comparisons", Expr.ParseFromString("{Male} == feminine", nullptr));
TestFalse("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue());
TestTrue("Comparisons", Expr.ParseFromString("{Male} == Feminine", nullptr));
TestFalse("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue());
TestTrue("Comparisons", Expr.ParseFromString("{Female} == Feminine", nullptr));
TestTrue("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue());
TestTrue("Comparisons", Expr.ParseFromString("{Female} != feminine", nullptr));
TestFalse("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue());
TestTrue("Comparisons", Expr.ParseFromString("{Female} == {AlsoFemale}", nullptr));
TestTrue("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue());
TestTrue("Comparisons", Expr.ParseFromString("{Female} != {Neuter}", nullptr));
TestTrue("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue());
TestTrue("Comparisons", Expr.ParseFromString("{Neuter} == neuter", nullptr));
TestTrue("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue());
TestTrue("Comparisons", Expr.ParseFromString("{Neuter} == Neuter", nullptr));
TestTrue("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue());
// Test local / global by defining the same variable
GlobalVariables.Add("GlobalLocalTestInt", 3);
Variables.Add("GlobalLocalTestInt", 20);
TestTrue("LocalTest", Expr.ParseFromString("{GlobalLocalTestInt} == 20", nullptr));
TestTrue("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue());
TestTrue("GlobalTest", Expr.ParseFromString("{global.GlobalLocalTestInt} == 3", nullptr));
TestTrue("Eval", Expr.Evaluate(Variables, GlobalVariables).GetBooleanValue());
return true;
};
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestBadExpressions,
"SUDSTest.TestBadExpressions",
EAutomationTestFlags::EditorContext |
EAutomationTestFlags::ClientContext |
EAutomationTestFlags::ProductFilter)
bool FTestBadExpressions::RunTest(const FString& Parameters)
{
FSUDSExpression Expr;
TMap<FName, FSUDSValue> Variables;
FString ParseError;
TestFalse("Missing operand", Expr.ParseFromString(" + 1", &ParseError));
TestTrue("Correct error", ParseError.Contains("Bad expression"));
TestFalse("Missing operand", Expr.ParseFromString("1 * ", &ParseError));
TestTrue("Correct error", ParseError.Contains("Bad expression"));
TestFalse("Missing parenthesis", Expr.ParseFromString("(3 + 1", &ParseError));
TestTrue("Correct error", ParseError.Contains("Mismatched parentheses"));
TestFalse("Missing parenthesis", Expr.ParseFromString("3 + 1)", &ParseError));
TestTrue("Correct error", ParseError.Contains("Mismatched parentheses"));
TestFalse("Invalid symbol", Expr.ParseFromString("something + 1", &ParseError));
TestTrue("Correct error", ParseError.Contains("Bad expression"));
return true;
}
UE_ENABLE_OPTIMIZATION

View File

@@ -0,0 +1,417 @@
#include "SUDSDialogue.h"
#include "SUDSLibrary.h"
#include "SUDSMessageLogger.h"
#include "SUDSScript.h"
#include "SUDSScriptImporter.h"
#include "TestUtils.h"
#include "Misc/AutomationTest.h"
UE_DISABLE_OPTIMIZATION
const FString GotoGosubInput = R"RAWSUD(
Player: Hello there
NPC: Hello
:choice
* Actually bye
NPC: How rude
[goto end]
* Reused
NPC: This is going to re-used dialogue
[gosub subroutine]
NPC: And now we're back
[goto goodbye]
* Gosub choice
NPC: This is going to return to the choice
[gosub subroutine]
* Choice After
NPC: It's a choice after a gosub!
[goto goodbye]
* Choice After 2
NPC: Not really much difference eh
[goto goodbye]
* Gosub choice via goto
NPC: This is going to return to the choice via goto
[gosub subroutine]
[goto choice]
:subroutine
Player: Some reused discussion
NPC: Yep, sure is
[return]
:goodbye
NPC: Bye!
)RAWSUD";
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestGotoGosub,
"SUDSTest.TestGotoGosub",
EAutomationTestFlags::EditorContext |
EAutomationTestFlags::ClientContext |
EAutomationTestFlags::ProductFilter)
bool FTestGotoGosub::RunTest(const FString& Parameters)
{
FSUDSMessageLogger Logger(false);
FSUDSScriptImporter Importer;
TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(GotoGosubInput), GotoGosubInput.Len(), "GotoGosubInput", &Logger, true));
auto Script = NewObject<USUDSScript>(GetTransientPackage(), "Test");
const ScopedStringTableHolder StringTableHolder;
Importer.PopulateAsset(Script, StringTableHolder.StringTable);
// Script shouldn't be the owner of the dialogue but it's the only UObject we've got right now so why not
auto Dlg = USUDSLibrary::CreateDialogue(Script, Script);
Dlg->Start();
TestDialogueText(this, "Start node", Dlg, "Player", "Hello there");
TestTrue("Continue", Dlg->Continue());
TestDialogueText(this, "Next", Dlg, "NPC", "Hello");
TestEqual("Choices", Dlg->GetNumberOfChoices(), 4);
TestEqual("Choice 0 text", Dlg->GetChoiceText(0).ToString(), "Actually bye");
TestTrue("Choose 0", Dlg->Choose(0));
TestDialogueText(this, "Next", Dlg, "NPC", "How rude");
TestFalse("End", Dlg->Continue());
Dlg->Restart();
TestDialogueText(this, "Start node", Dlg, "Player", "Hello there");
TestTrue("Continue", Dlg->Continue());
TestDialogueText(this, "Next", Dlg, "NPC", "Hello");
TestTrue("Choose 1", Dlg->Choose(1));
TestDialogueText(this, "Next", Dlg, "NPC", "This is going to re-used dialogue");
TestTrue("Continue", Dlg->Continue());
TestDialogueText(this, "Next", Dlg, "Player", "Some reused discussion");
TestTrue("Continue", Dlg->Continue());
TestDialogueText(this, "Next", Dlg, "NPC", "Yep, sure is");
TestTrue("Continue", Dlg->Continue());
TestDialogueText(this, "Next", Dlg, "NPC", "And now we're back");
TestTrue("Continue", Dlg->Continue());
TestDialogueText(this, "Next", Dlg, "NPC", "Bye!");
TestFalse("Continue", Dlg->Continue());
Dlg->Restart();
TestDialogueText(this, "Start node", Dlg, "Player", "Hello there");
TestTrue("Continue", Dlg->Continue());
TestDialogueText(this, "Next", Dlg, "NPC", "Hello");
TestTrue("Choose 2", Dlg->Choose(2));
TestDialogueText(this, "Next", Dlg, "NPC", "This is going to return to the choice");
TestTrue("Continue", Dlg->Continue());
TestDialogueText(this, "Next", Dlg, "Player", "Some reused discussion");
TestTrue("Continue", Dlg->Continue());
TestDialogueText(this, "Next", Dlg, "NPC", "Yep, sure is");
// Should have a choice at the end of it this time
TestEqual("Choices", Dlg->GetNumberOfChoices(), 2);
TestEqual("Choice 0 text", Dlg->GetChoiceText(0).ToString(), "Choice After");
TestEqual("Choice 1 text", Dlg->GetChoiceText(1).ToString(), "Choice After 2");
TestTrue("Choose 1", Dlg->Choose(1));
TestDialogueText(this, "Next", Dlg, "NPC", "Not really much difference eh");
TestTrue("Continue", Dlg->Continue());
TestDialogueText(this, "Next", Dlg, "NPC", "Bye!");
TestFalse("Continue", Dlg->Continue());
Dlg->Restart();
TestDialogueText(this, "Start node", Dlg, "Player", "Hello there");
TestTrue("Continue", Dlg->Continue());
TestDialogueText(this, "Next", Dlg, "NPC", "Hello");
TestTrue("Choose 2", Dlg->Choose(3));
TestDialogueText(this, "Next", Dlg, "NPC", "This is going to return to the choice via goto");
TestTrue("Continue", Dlg->Continue());
TestDialogueText(this, "Next", Dlg, "Player", "Some reused discussion");
TestTrue("Continue", Dlg->Continue());
TestDialogueText(this, "Next", Dlg, "NPC", "Yep, sure is");
// Should have a choice at the end of it this time
TestEqual("Choices", Dlg->GetNumberOfChoices(), 4);
TestEqual("Choice 0 text", Dlg->GetChoiceText(0).ToString(), "Actually bye");
TestTrue("Choose 0", Dlg->Choose(0));
TestDialogueText(this, "Next", Dlg, "NPC", "How rude");
TestFalse("End", Dlg->Continue());
Script->MarkAsGarbage();
return true;
}
const FString NestedGosubInput = R"RAWSUD(
Player: Hello there
[gosub sub1]
NPC: Back at level 0
[goto goodbye]
:sub1
Player: This is level 1
[gosub sub2_1]
[gosub sub2_2]
Player: End of level 1
[return]
:sub2_1
Player: This is level 2, sub 1
[return]
:sub2_2
Player: This is level 2, sub 2
NPC: We have to go deeper
[gosub sub3]
Player: End of level 2, sub 2
[return]
:sub3
Player: This is level 3
[return]
:goodbye
NPC: Bye!
)RAWSUD";
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestNestedGosub,
"SUDSTest.TestNestedGosub",
EAutomationTestFlags::EditorContext |
EAutomationTestFlags::ClientContext |
EAutomationTestFlags::ProductFilter)
bool FTestNestedGosub::RunTest(const FString& Parameters)
{
FSUDSMessageLogger Logger(false);
FSUDSScriptImporter Importer;
TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(NestedGosubInput), NestedGosubInput.Len(), "NestedGosubInput", &Logger, true));
auto Script = NewObject<USUDSScript>(GetTransientPackage(), "Test");
const ScopedStringTableHolder StringTableHolder;
Importer.PopulateAsset(Script, StringTableHolder.StringTable);
// Script shouldn't be the owner of the dialogue but it's the only UObject we've got right now so why not
auto Dlg = USUDSLibrary::CreateDialogue(Script, Script);
Dlg->Start();
TestDialogueText(this, "Start node", Dlg, "Player", "Hello there");
TestTrue("Continue", Dlg->Continue());
TestDialogueText(this, "Sub", Dlg, "Player", "This is level 1");
TestTrue("Continue", Dlg->Continue());
TestDialogueText(this, "Sub", Dlg, "Player", "This is level 2, sub 1");
TestTrue("Continue", Dlg->Continue());
TestDialogueText(this, "Sub", Dlg, "Player", "This is level 2, sub 2");
TestTrue("Continue", Dlg->Continue());
TestDialogueText(this, "Sub", Dlg, "NPC", "We have to go deeper");
TestTrue("Continue", Dlg->Continue());
TestDialogueText(this, "Sub", Dlg, "Player", "This is level 3");
TestTrue("Continue", Dlg->Continue());
TestDialogueText(this, "Sub", Dlg, "Player", "End of level 2, sub 2");
TestTrue("Continue", Dlg->Continue());
TestDialogueText(this, "Sub", Dlg, "Player", "End of level 1");
TestTrue("Continue", Dlg->Continue());
TestDialogueText(this, "Sub", Dlg, "NPC", "Back at level 0");
TestTrue("Continue", Dlg->Continue());
TestDialogueText(this, "Bye", Dlg, "NPC", "Bye!");
TestFalse("Continue", Dlg->Continue());
Script->MarkAsGarbage();
return true;
}
const FString GotoBetweenSpeakerAndChoiceInput = R"RAWSUD(
Player: Hello there
[if {SkipChoices}]
[goto end]
[endif]
* Option A
* Option B
)RAWSUD";
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestGotoBetweenSpeakerAndChoice1,
"SUDSTest.TestGotoBetweenSpeakerAndChoice1",
EAutomationTestFlags::EditorContext |
EAutomationTestFlags::ClientContext |
EAutomationTestFlags::ProductFilter)
bool FTestGotoBetweenSpeakerAndChoice1::RunTest(const FString& Parameters)
{
FSUDSMessageLogger Logger(false);
FSUDSScriptImporter Importer;
TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(GotoBetweenSpeakerAndChoiceInput), GotoBetweenSpeakerAndChoiceInput.Len(), "GotoBetweenSpeakerAndChoiceInput", &Logger, true));
auto Script = NewObject<USUDSScript>(GetTransientPackage(), "Test");
const ScopedStringTableHolder StringTableHolder;
Importer.PopulateAsset(Script, StringTableHolder.StringTable);
// Script shouldn't be the owner of the dialogue but it's the only UObject we've got right now so why not
auto Dlg = USUDSLibrary::CreateDialogue(Script, Script);
Dlg->Start();
if (TestEqual("Choice Count", Dlg->GetNumberOfChoices(), 2))
{
TestEqual("Choice 1", Dlg->GetChoiceText(0).ToString(), "Option A");
TestEqual("Choice 2", Dlg->GetChoiceText(1).ToString(), "Option B");
}
Script->MarkAsGarbage();
return true;
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestGotoBetweenSpeakerAndChoice2,
"SUDSTest.TestGotoBetweenSpeakerAndChoice2",
EAutomationTestFlags::EditorContext |
EAutomationTestFlags::ClientContext |
EAutomationTestFlags::ProductFilter)
bool FTestGotoBetweenSpeakerAndChoice2::RunTest(const FString& Parameters)
{
FSUDSMessageLogger Logger(false);
FSUDSScriptImporter Importer;
TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(GotoBetweenSpeakerAndChoiceInput), GotoBetweenSpeakerAndChoiceInput.Len(), "GotoBetweenSpeakerAndChoiceInput", &Logger, true));
auto Script = NewObject<USUDSScript>(GetTransientPackage(), "Test");
const ScopedStringTableHolder StringTableHolder;
Importer.PopulateAsset(Script, StringTableHolder.StringTable);
// Script shouldn't be the owner of the dialogue but it's the only UObject we've got right now so why not
auto Dlg = USUDSLibrary::CreateDialogue(Script, Script);
Dlg->SetVariableBoolean("SkipChoices", true);
Dlg->Start();
TestDialogueText(this, "Start node", Dlg, "Player", "Hello there");
TestTrue("Plain continue", Dlg->IsSimpleContinue());
Script->MarkAsGarbage();
return true;
}
const FString GosubBetweenSpeakerAndChoiceInput1 = R"RAWSUD(
Player: Hello there
[gosub MaybeSkipChoices]
* Option A
* Option B
[goto end]
:MaybeSkipChoices
[if {SkipChoices}]
[goto end]
[endif]
[return]
)RAWSUD";
const FString GosubBetweenSpeakerAndChoiceInput2 = R"RAWSUD(
Player: Hello there
[gosub PrintDebug]
[gosub MaybeSkipChoices]
* Option A
* Option B
[goto end]
:PrintDebug
Debug: SkipChoices is {SkipChoices}
[return]
:MaybeSkipChoices
[if {SkipChoices}]
[goto end]
[endif]
Speaker: Another sentence.
[return]
)RAWSUD";
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestGosubBetweenSpeakerAndChoice1,
"SUDSTest.TestGosubBetweenSpeakerAndChoice1",
EAutomationTestFlags::EditorContext |
EAutomationTestFlags::ClientContext |
EAutomationTestFlags::ProductFilter)
bool FTestGosubBetweenSpeakerAndChoice1::RunTest(const FString& Parameters)
{
FSUDSMessageLogger Logger(false);
FSUDSScriptImporter Importer;
TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(GosubBetweenSpeakerAndChoiceInput1), GosubBetweenSpeakerAndChoiceInput1.Len(), "GosubBetweenSpeakerAndChoiceInput1", &Logger, true));
auto Script = NewObject<USUDSScript>(GetTransientPackage(), "Test");
const ScopedStringTableHolder StringTableHolder;
Importer.PopulateAsset(Script, StringTableHolder.StringTable);
// Script shouldn't be the owner of the dialogue but it's the only UObject we've got right now so why not
auto Dlg = USUDSLibrary::CreateDialogue(Script, Script);
Dlg->Start();
TestDialogueText(this, "Start node", Dlg, "Player", "Hello there");
if (TestEqual("Choice Count", Dlg->GetNumberOfChoices(), 2))
{
TestEqual("Choice 1", Dlg->GetChoiceText(0).ToString(), "Option A");
TestEqual("Choice 2", Dlg->GetChoiceText(1).ToString(), "Option B");
}
// Now test the true case
Dlg->SetVariableBoolean("SkipChoices", true);
Dlg->Restart(false);
TestDialogueText(this, "Start node", Dlg, "Player", "Hello there");
TestTrue("Plain continue", Dlg->IsSimpleContinue());
Script->MarkAsGarbage();
return true;
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestGosubBetweenSpeakerAndChoice2,
"SUDSTest.TestGosubBetweenSpeakerAndChoice2",
EAutomationTestFlags::EditorContext |
EAutomationTestFlags::ClientContext |
EAutomationTestFlags::ProductFilter)
bool FTestGosubBetweenSpeakerAndChoice2::RunTest(const FString& Parameters)
{
FSUDSMessageLogger Logger(false);
FSUDSScriptImporter Importer;
TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(GosubBetweenSpeakerAndChoiceInput2), GosubBetweenSpeakerAndChoiceInput2.Len(), "GosubBetweenSpeakerAndChoiceInput2", &Logger, true));
auto Script = NewObject<USUDSScript>(GetTransientPackage(), "Test");
const ScopedStringTableHolder StringTableHolder;
Importer.PopulateAsset(Script, StringTableHolder.StringTable);
// Script shouldn't be the owner of the dialogue but it's the only UObject we've got right now so why not
auto Dlg = USUDSLibrary::CreateDialogue(Script, Script);
Dlg->Start();
TestDialogueText(this, "Start node", Dlg, "Player", "Hello there");
// in this case, there's another speaker line in both nested gosubs so the choices are actually associated with the
// last of those.
TestTrue("Plain continue", Dlg->Continue());
TestDialogueText(this, "Gosub 1 speaker node", Dlg, "Debug", "SkipChoices is {SkipChoices}");
TestTrue("Plain continue", Dlg->Continue());
TestDialogueText(this, "Gosub 2 speaker node", Dlg, "Speaker", "Another sentence.");
if (TestEqual("Choice Count", Dlg->GetNumberOfChoices(), 2))
{
TestEqual("Choice 1", Dlg->GetChoiceText(0).ToString(), "Option A");
TestEqual("Choice 2", Dlg->GetChoiceText(1).ToString(), "Option B");
}
// Now test the true case
Dlg->SetVariableBoolean("SkipChoices", true);
Dlg->Restart(false);
TestDialogueText(this, "Start node", Dlg, "Player", "Hello there");
TestTrue("Plain continue", Dlg->Continue());
TestDialogueText(this, "Gosub 1 speaker node", Dlg, "Debug", "SkipChoices is 1");
TestTrue("Plain continue", Dlg->IsSimpleContinue());
Script->MarkAsGarbage();
return true;
}
UE_ENABLE_OPTIMIZATION

View File

@@ -0,0 +1,133 @@
#include "SUDSDialogue.h"
#include "SUDSLibrary.h"
#include "SUDSMessageLogger.h"
#include "SUDSScript.h"
#include "SUDSScriptImporter.h"
#include "TestUtils.h"
#include "Internationalization/StringTableCore.h"
#include "Misc/AutomationTest.h"
UE_DISABLE_OPTIMIZATION
// Assign known keys to the strings so we can detect
const FString MetadataInput = R"RAWSUD(
# No metadata
Player: Hello there @001@
#= Transient metadata for next line
#= TransientData: Something here
NPC: Hello @002@
# Should have no metadata anymore
Player: Well this is nice @003@
#+ Persistent metadata
#+ PersistentData: Something longer lived
NPC: Isn't it though @004@
# Should still apply here
Player: Indeed @005@
#= Test overriding temporarily
#= PersistentData: This should override
#= ExtraTransient: This should be new only for next line
NPC: Well well @006@
Player: Metadata should have gone back now @007@
#+ Persistent metadata changed now
Player: Persistent change @008@
* Some choice @009@
NPC: How rude @010@
[goto end]
#= Temporary comment
* Another choice @011@
NPC: This should be back now @012@
#+ Persistent indented change 1
* One more choice @013@
#+ Even more indented
#+ NestedKey: This will disappear fast even though persistent
NPC: Ooops @014@
* Final choice @015@
NPC: This is going to return to the choice via goto @016@
# Nested meta should have been lost but outer meta should be back
Player: Something something @017@
)RAWSUD";
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestMetadata,
"SUDSTest.TestMetadata",
EAutomationTestFlags::EditorContext |
EAutomationTestFlags::ClientContext |
EAutomationTestFlags::ProductFilter)
bool FTestMetadata::RunTest(const FString& Parameters)
{
FSUDSMessageLogger Logger(false);
FSUDSScriptImporter Importer;
TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(MetadataInput), MetadataInput.Len(), "MetadataInput", &Logger, true));
auto Script = NewObject<USUDSScript>(GetTransientPackage(), "Test");
const ScopedStringTableHolder StringTableHolder;
Importer.PopulateAsset(Script, StringTableHolder.StringTable);
// In this test we're only interested in the string table metadata
// Have to get the mutable string table to get at metadata
auto StrTable = StringTableHolder.StringTable->GetMutableStringTable();
TestEqual("Line 1 Comment", StrTable->GetMetaData(FTextKey("@001@"), FName("Comment")), "");
TestEqual("Line 1 Speaker", StrTable->GetMetaData(FTextKey("@001@"), FName("Speaker")), "Player");
TestEqual("Line 2 Comment", StrTable->GetMetaData(FTextKey("@002@"), FName("Comment")), "Transient metadata for next line");
TestEqual("Line 2 Speaker", StrTable->GetMetaData(FTextKey("@002@"), FName("Speaker")), "NPC");
TestEqual("Line 2 Custom", StrTable->GetMetaData(FTextKey("@002@"), FName("TransientData")), "Something here");
TestEqual("Line 3 Comment (should have reset)", StrTable->GetMetaData(FTextKey("@003@"), FName("Comment")), "");
TestEqual("Line 3 Speaker", StrTable->GetMetaData(FTextKey("@003@"), FName("Speaker")), "Player");
TestEqual("Line 3 Custom (should have reset)", StrTable->GetMetaData(FTextKey("@003@"), FName("TransientData")), "");
TestEqual("Line 4 Comment", StrTable->GetMetaData(FTextKey("@004@"), FName("Comment")), "Persistent metadata");
TestEqual("Line 4 Speaker", StrTable->GetMetaData(FTextKey("@004@"), FName("Speaker")), "NPC");
TestEqual("Line 4 Custom", StrTable->GetMetaData(FTextKey("@004@"), FName("PersistentData")), "Something longer lived");
TestEqual("Line 5 Comment", StrTable->GetMetaData(FTextKey("@005@"), FName("Comment")), "Persistent metadata");
TestEqual("Line 5 Speaker", StrTable->GetMetaData(FTextKey("@005@"), FName("Speaker")), "Player");
TestEqual("Line 5 Custom", StrTable->GetMetaData(FTextKey("@005@"), FName("PersistentData")), "Something longer lived");
TestEqual("Line 6 Comment", StrTable->GetMetaData(FTextKey("@006@"), FName("Comment")), "Test overriding temporarily");
TestEqual("Line 6 Speaker", StrTable->GetMetaData(FTextKey("@006@"), FName("Speaker")), "NPC");
TestEqual("Line 6 Custom", StrTable->GetMetaData(FTextKey("@006@"), FName("PersistentData")), "This should override");
TestEqual("Line 6 Custom 2", StrTable->GetMetaData(FTextKey("@006@"), FName("ExtraTransient")), "This should be new only for next line");
TestEqual("Line 7 Comment", StrTable->GetMetaData(FTextKey("@007@"), FName("Comment")), "Persistent metadata");
TestEqual("Line 7 Speaker", StrTable->GetMetaData(FTextKey("@007@"), FName("Speaker")), "Player");
TestEqual("Line 7 Custom", StrTable->GetMetaData(FTextKey("@007@"), FName("PersistentData")), "Something longer lived");
TestEqual("Line 8 Comment", StrTable->GetMetaData(FTextKey("@008@"), FName("Comment")), "Persistent metadata changed now");
TestEqual("Line 8 Speaker", StrTable->GetMetaData(FTextKey("@008@"), FName("Speaker")), "Player");
TestEqual("Line 8 Custom", StrTable->GetMetaData(FTextKey("@008@"), FName("PersistentData")), "Something longer lived");
TestEqual("Line 9 Comment", StrTable->GetMetaData(FTextKey("@009@"), FName("Comment")), "Persistent metadata changed now");
TestEqual("Line 9 Speaker", StrTable->GetMetaData(FTextKey("@009@"), FName("Speaker")), "Player (Choice)");
TestEqual("Line 9 Custom", StrTable->GetMetaData(FTextKey("@009@"), FName("PersistentData")), "Something longer lived");
TestEqual("Line 10 Comment", StrTable->GetMetaData(FTextKey("@010@"), FName("Comment")), "Persistent metadata changed now");
TestEqual("Line 10 Speaker", StrTable->GetMetaData(FTextKey("@010@"), FName("Speaker")), "NPC");
TestEqual("Line 10 Custom", StrTable->GetMetaData(FTextKey("@010@"), FName("PersistentData")), "Something longer lived");
TestEqual("Line 11 Comment", StrTable->GetMetaData(FTextKey("@011@"), FName("Comment")), "Temporary comment");
TestEqual("Line 11 Speaker", StrTable->GetMetaData(FTextKey("@011@"), FName("Speaker")), "Player (Choice)");
TestEqual("Line 11 Custom", StrTable->GetMetaData(FTextKey("@011@"), FName("PersistentData")), "Something longer lived");
TestEqual("Line 12 Comment", StrTable->GetMetaData(FTextKey("@012@"), FName("Comment")), "Persistent metadata changed now");
TestEqual("Line 12 Speaker", StrTable->GetMetaData(FTextKey("@012@"), FName("Speaker")), "NPC");
TestEqual("Line 12 Custom", StrTable->GetMetaData(FTextKey("@012@"), FName("PersistentData")), "Something longer lived");
TestEqual("Line 13 Comment", StrTable->GetMetaData(FTextKey("@013@"), FName("Comment")), "Persistent indented change 1");
TestEqual("Line 13 Speaker", StrTable->GetMetaData(FTextKey("@013@"), FName("Speaker")), "Player (Choice)");
TestEqual("Line 13 Custom", StrTable->GetMetaData(FTextKey("@013@"), FName("PersistentData")), "Something longer lived");
TestEqual("Line 14 Comment", StrTable->GetMetaData(FTextKey("@014@"), FName("Comment")), "Even more indented");
TestEqual("Line 14 Speaker", StrTable->GetMetaData(FTextKey("@014@"), FName("Speaker")), "NPC");
TestEqual("Line 14 Custom", StrTable->GetMetaData(FTextKey("@014@"), FName("PersistentData")), "Something longer lived");
TestEqual("Line 14 Custom 2", StrTable->GetMetaData(FTextKey("@014@"), FName("NestedKey")), "This will disappear fast even though persistent");
TestEqual("Line 15 Comment", StrTable->GetMetaData(FTextKey("@015@"), FName("Comment")), "Persistent indented change 1"); // should have gone back
TestEqual("Line 15 Speaker", StrTable->GetMetaData(FTextKey("@015@"), FName("Speaker")), "Player (Choice)");
TestEqual("Line 15 Custom", StrTable->GetMetaData(FTextKey("@015@"), FName("PersistentData")), "Something longer lived");
TestEqual("Line 15 Custom 2", StrTable->GetMetaData(FTextKey("@015@"), FName("NestedKey")), "");
TestEqual("Line 16 Comment", StrTable->GetMetaData(FTextKey("@016@"), FName("Comment")), "Persistent indented change 1"); // should have gone back
TestEqual("Line 16 Speaker", StrTable->GetMetaData(FTextKey("@016@"), FName("Speaker")), "NPC");
TestEqual("Line 16 Custom", StrTable->GetMetaData(FTextKey("@016@"), FName("PersistentData")), "Something longer lived");
TestEqual("Line 16 Custom 2", StrTable->GetMetaData(FTextKey("@016@"), FName("NestedKey")), "");
TestEqual("Line 17 Comment", StrTable->GetMetaData(FTextKey("@017@"), FName("Comment")), "Persistent metadata changed now"); // should have gone back to top level
TestEqual("Line 17 Speaker", StrTable->GetMetaData(FTextKey("@017@"), FName("Speaker")), "Player");
TestEqual("Line 17 Custom", StrTable->GetMetaData(FTextKey("@017@"), FName("PersistentData")), "Something longer lived");
Script->MarkAsGarbage();
return true;
}
UE_ENABLE_OPTIMIZATION

View File

@@ -0,0 +1,147 @@
#include "SUDSDialogue.h"
#include "SUDSLibrary.h"
#include "SUDSMessageLogger.h"
#include "SUDSScript.h"
#include "SUDSScriptImporter.h"
#include "TestParticipant.h"
#include "TestUtils.h"
#include "Internationalization/Internationalization.h"
#include "Misc/AutomationTest.h"
UE_DISABLE_OPTIMIZATION
const FString ParamsInput = R"RAWSUD(
Player: Hello, I'm {SpeakerName.Player}
NPC: Greetings, {SpeakerName.Player}, my name is {SpeakerName.NPC}
Player: My friend's name is {FriendName}, {Gender}|gender(he,she,they) {Gender}|gender(has,has,have) {NumCats} {NumCats}|plural(one=cat,other=cats)
NPC: Floating point {FloatVal} format test
Player: Boolean test {BoolVal}?
* Choose, {SpeakerName.Player}!
* Is {NumCats} {NumCats}|plural(one=cat,other=cats) too many?
NPC: No, {numcats} is fine
)RAWSUD";
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestParameters,
"SUDSTest.TestParameters",
EAutomationTestFlags::EditorContext |
EAutomationTestFlags::ClientContext |
EAutomationTestFlags::ProductFilter)
bool FTestParameters::RunTest(const FString& Parameters)
{
// Number and plural formatting are locale-specific, so we must set it to a predefined value to produce the expected output
FInternationalization::FCultureStateSnapshot CultureStateSnapshot;
FInternationalization::Get().BackupCultureState(CultureStateSnapshot);
FInternationalization::Get().SetCurrentCulture(TEXT("en-US"));
ON_SCOPE_EXIT
{
FInternationalization::Get().RestoreCultureState(CultureStateSnapshot);
};
FSUDSMessageLogger Logger(false);
FSUDSScriptImporter Importer;
TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(ParamsInput), ParamsInput.Len(), "ParamsInput", &Logger, true));
auto Script = NewObject<USUDSScript>(GetTransientPackage(), "Test");
const ScopedStringTableHolder StringTableHolder;
Importer.PopulateAsset(Script, StringTableHolder.StringTable);
// Script shouldn't be the owner of the dialogue but it's the only UObject we've got right now so why not
auto Dlg = USUDSLibrary::CreateDialogue(Script, Script);
auto Participant = NewObject<UTestParticipant>();
Participant->TestNumber = 0;
Dlg->AddParticipant(Participant);
Dlg->Start();
TestDialogueText(this, "Line 1", Dlg, "Player", "Hello, I'm Protagonist");
Dlg->Continue();
TestDialogueText(this, "Line 2", Dlg, "NPC", "Greetings, Protagonist, my name is An NPC");
Dlg->Continue();
TestDialogueText(this, "Line 3", Dlg, "Player", "My friend's name is Susan, she has 3 cats");
Dlg->Continue();
TestDialogueText(this, "Line 4", Dlg, "NPC", "Floating point 12.567 format test");
Dlg->Continue();
TestDialogueText(this, "Line 5", Dlg, "Player", "Boolean test 1?");
if (TestEqual("Number of choices", Dlg->GetNumberOfChoices(), 2))
{
TestEqual("Choice text 1", Dlg->GetChoiceText(0).ToString(), "Choose, Protagonist!");
TestEqual("Choice text 2", Dlg->GetChoiceText(1).ToString(), "Is 3 cats too many?");
}
// test case insensitivity, this next line uses {numcats} instead of {NumCats}
Dlg->Choose(1);
TestDialogueText(this, "Line 6", Dlg, "NPC", "No, 3 is fine");
Script->MarkAsGarbage();
return true;
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestParametersPriority,
"SUDSTest.TestParametersPriority",
EAutomationTestFlags::EditorContext |
EAutomationTestFlags::ClientContext |
EAutomationTestFlags::ProductFilter)
bool FTestParametersPriority::RunTest(const FString& Parameters)
{
// Number and plural formatting are locale-specific, so we must set it to a predefined value to produce the expected output
FInternationalization::FCultureStateSnapshot CultureStateSnapshot;
FInternationalization::Get().BackupCultureState(CultureStateSnapshot);
FInternationalization::Get().SetCurrentCulture(TEXT("en-US"));
ON_SCOPE_EXIT
{
FInternationalization::Get().RestoreCultureState(CultureStateSnapshot);
};
FSUDSMessageLogger Logger(false);
FSUDSScriptImporter Importer;
TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(ParamsInput), ParamsInput.Len(), "ParamsInput", &Logger, true));
auto Script = NewObject<USUDSScript>(GetTransientPackage(), "Test");
const ScopedStringTableHolder StringTableHolder;
Importer.PopulateAsset(Script, StringTableHolder.StringTable);
// Script shouldn't be the owner of the dialogue but it's the only UObject we've got right now so why not
auto Dlg = USUDSLibrary::CreateDialogue(Script, Script);
auto Participant1 = NewObject<UTestParticipant>();
Participant1->TestNumber = 0; // priority 0
auto Participant2 = NewObject<UTestParticipant>();
Participant2->TestNumber = 1; // priority 100
auto Participant3 = NewObject<UTestParticipant>();
Participant1->TestNumber = 2; // priority -200
Dlg->AddParticipant(Participant1);
Dlg->AddParticipant(Participant2);
Dlg->AddParticipant(Participant3);
Dlg->Start();
// Ordering of the participants should be from low to high, so variables from higher priority value participant should
// have overridden lower priority ones
// All of these are set by Participant 2 who is higher priority
TestDialogueText(this, "Line 1", Dlg, "Player", "Hello, I'm Hero");
Dlg->Continue();
TestDialogueText(this, "Line 2", Dlg, "NPC", "Greetings, Hero, my name is Bob The NPC");
Dlg->Continue();
TestDialogueText(this, "Line 3", Dlg, "Player", "My friend's name is Derek, he has 5 cats");
Dlg->Continue();
// These are set by Participant1 and unchanged by 2. 3 has tried to set float but should have been overridden
TestDialogueText(this, "Line 4", Dlg, "NPC", "Floating point 12.567 format test");
Dlg->Continue();
TestDialogueText(this, "Line 5", Dlg, "Player", "Boolean test 1?");
// Check that there's a variable from Participant2 which no-one else set
TestEqual("Participant3 should have set something", Dlg->GetVariableInt("SomethingUniqueTo3"), 120);
Script->MarkAsGarbage();
return true;
}
UE_ENABLE_OPTIMIZATION

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,68 @@
#include "TestParticipant.h"
#include "SUDSDialogue.h"
void UTestParticipant::OnDialogueStarting_Implementation(USUDSDialogue* Dialogue, FName AtLabel)
{
switch(TestNumber)
{
default:
case 0:
Dialogue->SetVariable("SpeakerName.Player", FText::FromString("Protagonist"));
Dialogue->SetVariable("SpeakerName.NPC", FText::FromString("An NPC"));
Dialogue->SetVariable("NumCats", 3);
Dialogue->SetVariableText("FriendName", FText::FromString("Susan"));
Dialogue->SetVariable("Gender", ETextGender::Feminine);
Dialogue->SetVariableFloat("FloatVal", 12.567);
Dialogue->SetVariableBoolean("BoolVal", true);
break;
case 1:
Dialogue->SetVariable("SpeakerName.Player", FText::FromString("Hero"));
Dialogue->SetVariable("SpeakerName.NPC", FText::FromString("Bob The NPC"));
Dialogue->SetVariableText("FriendName", FText::FromString("Derek"));
Dialogue->SetVariable("Gender", ETextGender::Masculine);
Dialogue->SetVariable("NumCats", 5);
break;
case 2:
// these will all be overridden by higher priorities
Dialogue->SetVariable("SpeakerName.Player", FText::FromString("Dweeb"));
Dialogue->SetVariable("SpeakerName.NPC", FText::FromString("Imaginary Friend"));
Dialogue->SetVariable("NumCats", 10);
Dialogue->SetVariableFloat("FloatVal", 0.002);
// This one will be unique and so will still get through
Dialogue->SetVariableInt("SomethingUniqueTo3", 120);
break;
}
}
int UTestParticipant::GetDialogueParticipantPriority_Implementation() const
{
switch(TestNumber)
{
default:
case 0:
return 0;
case 1:
return 100;
case 2:
return -200;
}
}
void UTestParticipant::OnDialogueEvent_Implementation(USUDSDialogue* Dialogue,
FName EventName,
const TArray<FSUDSValue>& Arguments)
{
EventRecords.Add(FEventRecord { EventName, Arguments });
}
void UTestParticipant::OnDialogueVariableChanged_Implementation(USUDSDialogue* Dialogue,
FName VariableName,
const FSUDSValue& Value,
bool bFromScript)
{
SetVarRecords.Add(FSetVarRecord { VariableName, Value, bFromScript });
}

View File

@@ -0,0 +1,45 @@
#pragma once
#include "CoreMinimal.h"
#include "SUDSParticipant.h"
#include "SUDSValue.h"
#include "UObject/Object.h"
#include "TestParticipant.generated.h"
/**
*
*/
UCLASS()
class SUDSTEST_API UTestParticipant : public UObject, public ISUDSParticipant
{
GENERATED_BODY()
public:
int TestNumber = 0;
struct FEventRecord
{
FName Name;
TArray<FSUDSValue> Args;
};
struct FSetVarRecord
{
FName Name;
FSUDSValue Value;
bool bFromScript;
};
TArray<FEventRecord> EventRecords;
TArray<FSetVarRecord> SetVarRecords;
virtual void OnDialogueStarting_Implementation(USUDSDialogue* Dialogue, FName AtLabel) override;
virtual int GetDialogueParticipantPriority_Implementation() const override;
virtual void OnDialogueEvent_Implementation(USUDSDialogue* Dialogue,
FName EventName,
const TArray<FSUDSValue>& Arguments) override;
virtual void OnDialogueVariableChanged_Implementation(USUDSDialogue* Dialogue,
FName VariableName,
const FSUDSValue& Value,
bool bFromScript) override;
};

View File

@@ -0,0 +1,342 @@
#include "SUDSLibrary.h"
#include "SUDSMessageLogger.h"
#include "SUDSScript.h"
#include "SUDSScriptImporter.h"
#include "TestUtils.h"
UE_DISABLE_OPTIMIZATION
const FString BasicRandomInput = R"RAWSUD(
Player: Hello
:start
[random]
NPC: Reply when random == 0
[or]
NPC: Reply when random == 1
[or]
NPC: Reply when random == 2
[endrandom]
Player: OK
[goto start]
)RAWSUD";
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestRandomBasics,
"SUDSTest.TestRandomBasics",
EAutomationTestFlags::EditorContext |
EAutomationTestFlags::ClientContext |
EAutomationTestFlags::ProductFilter)
bool FTestRandomBasics::RunTest(const FString& Parameters)
{
FSUDSMessageLogger Logger(false);
FSUDSScriptImporter Importer;
TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(BasicRandomInput), BasicRandomInput.Len(), "BasicRandomInput", &Logger, true));
auto Script = NewObject<USUDSScript>(GetTransientPackage(), "Test");
const ScopedStringTableHolder StringTableHolder;
Importer.PopulateAsset(Script, StringTableHolder.StringTable);
// Script shouldn't be the owner of the dialogue but it's the only UObject we've got right now so why not
auto Dlg = USUDSLibrary::CreateDialogue(Script, Script);
// Seed random so we have consistent results
FMath::SRandInit(34);
Dlg->Start();
TestDialogueText(this, "Text node", Dlg, "Player", "Hello");
TestTrue("Continue", Dlg->Continue());
TestDialogueText(this, "Random node", Dlg, "NPC", "Reply when random == 2");
TestTrue("Continue", Dlg->Continue());
TestDialogueText(this, "Final node", Dlg, "Player", "OK");
// Should loop, run random again
TestTrue("Continue", Dlg->Continue());
TestDialogueText(this, "Random node", Dlg, "NPC", "Reply when random == 1");
TestTrue("Continue", Dlg->Continue());
TestDialogueText(this, "Final node", Dlg, "Player", "OK");
// Should loop, run random again
TestTrue("Continue", Dlg->Continue());
TestDialogueText(this, "Random node", Dlg, "NPC", "Reply when random == 0");
TestTrue("Continue", Dlg->Continue());
TestDialogueText(this, "Final node", Dlg, "Player", "OK");
// Should loop, run random again
TestTrue("Continue", Dlg->Continue());
TestDialogueText(this, "Random node", Dlg, "NPC", "Reply when random == 1");
TestTrue("Continue", Dlg->Continue());
TestDialogueText(this, "Final node", Dlg, "Player", "OK");
Script->MarkAsGarbage();
return true;
}
const FString NestedRandomInput = R"RAWSUD(
Player: Hello
:start
[random]
NPC: Reply when random == 0
[or]
[random]
NPC: Reply when random == 1 && subrandom == 0
[or]
NPC: Reply when random == 1 && subrandom == 1
[endrandom]
[or]
NPC: Reply when random == 2
[endrandom]
Player: OK
[goto start]
)RAWSUD";
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestRandomNested,
"SUDSTest.TestRandomNested",
EAutomationTestFlags::EditorContext |
EAutomationTestFlags::ClientContext |
EAutomationTestFlags::ProductFilter)
bool FTestRandomNested::RunTest(const FString& Parameters)
{
FSUDSMessageLogger Logger(false);
FSUDSScriptImporter Importer;
TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(NestedRandomInput), NestedRandomInput.Len(), "NestedRandomInput", &Logger, true));
auto Script = NewObject<USUDSScript>(GetTransientPackage(), "Test");
const ScopedStringTableHolder StringTableHolder;
Importer.PopulateAsset(Script, StringTableHolder.StringTable);
// Script shouldn't be the owner of the dialogue but it's the only UObject we've got right now so why not
auto Dlg = USUDSLibrary::CreateDialogue(Script, Script);
// Seed random so we have consistent results
FMath::SRandInit(785);
Dlg->Start();
TestDialogueText(this, "Text node", Dlg, "Player", "Hello");
TestTrue("Continue", Dlg->Continue());
TestDialogueText(this, "Random node", Dlg, "NPC", "Reply when random == 0");
TestTrue("Continue", Dlg->Continue());
TestDialogueText(this, "Final node", Dlg, "Player", "OK");
TestTrue("Continue", Dlg->Continue());
// Restart
TestDialogueText(this, "Random node", Dlg, "NPC", "Reply when random == 1 && subrandom == 1");
TestTrue("Continue", Dlg->Continue());
TestDialogueText(this, "Final node", Dlg, "Player", "OK");
TestTrue("Continue", Dlg->Continue());
// Restart
TestDialogueText(this, "Random node", Dlg, "NPC", "Reply when random == 1 && subrandom == 0");
TestTrue("Continue", Dlg->Continue());
TestDialogueText(this, "Final node", Dlg, "Player", "OK");
TestTrue("Continue", Dlg->Continue());
// Restart
TestDialogueText(this, "Random node", Dlg, "NPC", "Reply when random == 2");
TestTrue("Continue", Dlg->Continue());
TestDialogueText(this, "Final node", Dlg, "Player", "OK");
TestTrue("Continue", Dlg->Continue());
// Restart
TestDialogueText(this, "Random node", Dlg, "NPC", "Reply when random == 0");
TestTrue("Continue", Dlg->Continue());
TestDialogueText(this, "Final node", Dlg, "Player", "OK");
TestTrue("Continue", Dlg->Continue());
Script->MarkAsGarbage();
return true;
}
const FString SiblingRandomInput = R"RAWSUD(
Player: Hello
:start
[random]
NPC: Reply when random == 0
[or]
NPC: Reply when random == 1
[or]
NPC: Reply when random == 2
[endrandom]
[random]
NPC: Second random == 0
[or]
NPC: Second random == 1
[or]
NPC: Second random == 2
[endrandom]
Player: OK
[goto start]
)RAWSUD";
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestRandomSibling,
"SUDSTest.TestRandomSibling",
EAutomationTestFlags::EditorContext |
EAutomationTestFlags::ClientContext |
EAutomationTestFlags::ProductFilter)
bool FTestRandomSibling::RunTest(const FString& Parameters)
{
FSUDSMessageLogger Logger(false);
FSUDSScriptImporter Importer;
TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(SiblingRandomInput), SiblingRandomInput.Len(), "SiblingRandomInput", &Logger, true));
auto Script = NewObject<USUDSScript>(GetTransientPackage(), "Test");
const ScopedStringTableHolder StringTableHolder;
Importer.PopulateAsset(Script, StringTableHolder.StringTable);
// Script shouldn't be the owner of the dialogue but it's the only UObject we've got right now so why not
auto Dlg = USUDSLibrary::CreateDialogue(Script, Script);
// Seed random so we have consistent results
FMath::SRandInit(2376);
Dlg->Start();
TestDialogueText(this, "Text node", Dlg, "Player", "Hello");
TestTrue("Continue", Dlg->Continue());
TestDialogueText(this, "Text node", Dlg, "NPC", "Reply when random == 1");
TestTrue("Continue", Dlg->Continue());
TestDialogueText(this, "Text node", Dlg, "NPC", "Second random == 0");
TestTrue("Continue", Dlg->Continue());
TestDialogueText(this, "Text node", Dlg, "Player", "OK");
TestTrue("Continue", Dlg->Continue());
// Restart
TestDialogueText(this, "Text node", Dlg, "NPC", "Reply when random == 0");
TestTrue("Continue", Dlg->Continue());
TestDialogueText(this, "Text node", Dlg, "NPC", "Second random == 2");
TestTrue("Continue", Dlg->Continue());
TestDialogueText(this, "Text node", Dlg, "Player", "OK");
TestTrue("Continue", Dlg->Continue());
// Restart
TestDialogueText(this, "Text node", Dlg, "NPC", "Reply when random == 1");
TestTrue("Continue", Dlg->Continue());
TestDialogueText(this, "Text node", Dlg, "NPC", "Second random == 2");
TestTrue("Continue", Dlg->Continue());
TestDialogueText(this, "Text node", Dlg, "Player", "OK");
TestTrue("Continue", Dlg->Continue());
// Restart
TestDialogueText(this, "Text node", Dlg, "NPC", "Reply when random == 0");
TestTrue("Continue", Dlg->Continue());
TestDialogueText(this, "Text node", Dlg, "NPC", "Second random == 0");
TestTrue("Continue", Dlg->Continue());
TestDialogueText(this, "Text node", Dlg, "Player", "OK");
TestTrue("Continue", Dlg->Continue());
// Restart
TestDialogueText(this, "Text node", Dlg, "NPC", "Reply when random == 2");
TestTrue("Continue", Dlg->Continue());
TestDialogueText(this, "Text node", Dlg, "NPC", "Second random == 2");
TestTrue("Continue", Dlg->Continue());
TestDialogueText(this, "Text node", Dlg, "Player", "OK");
TestTrue("Continue", Dlg->Continue());
Script->MarkAsGarbage();
return true;
}
const FString MixedConditionalChoiceAndRandomInput = R"RAWSUD(
Player: Hello
* First choice
Player: I took the 1.1 choice
[if {x} > 0]
* Second choice (conditional)
[random]
Player: I took the 1.2 choice, random == 0
[or]
Player: I took the 1.2 choice, random == 1
[endif]
* Third choice (conditional)
Player: I took the 1.3 choice
[else]
* Second Alt Choice
[random]
Player: I took the alt 1.2 choice, random == 0
[or]
Player: I took the alt 1.2 choice, random == 1
[endif]
[endif]
* Common last choice
Player: I took the 1.4 choice
)RAWSUD";
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestRandomMixed,
"SUDSTest.TestRandomMixed",
EAutomationTestFlags::EditorContext |
EAutomationTestFlags::ClientContext |
EAutomationTestFlags::ProductFilter)
bool FTestRandomMixed::RunTest(const FString& Parameters)
{
FSUDSMessageLogger Logger(false);
FSUDSScriptImporter Importer;
TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(MixedConditionalChoiceAndRandomInput), MixedConditionalChoiceAndRandomInput.Len(), "MixedRandomInput", &Logger, true));
auto Script = NewObject<USUDSScript>(GetTransientPackage(), "Test");
const ScopedStringTableHolder StringTableHolder;
Importer.PopulateAsset(Script, StringTableHolder.StringTable);
// Script shouldn't be the owner of the dialogue but it's the only UObject we've got right now so why not
auto Dlg = USUDSLibrary::CreateDialogue(Script, Script);
// Seed random so we have consistent results
FMath::SRandInit(999);
Dlg->SetVariableInt("x", 5);
Dlg->Start();
TestDialogueText(this, "Start node", Dlg, "Player", "Hello");
// 2 choices from conditional, 2 common
if (TestEqual("Choice count when x=5", Dlg->GetNumberOfChoices(), 4))
{
TestTrue("Choose 1", Dlg->Choose(1));
TestDialogueText(this, "Text node", Dlg, "Player", "I took the 1.2 choice, random == 0");
}
// Restart, same again but different result if random
Dlg->Restart(false);
TestDialogueText(this, "Start node", Dlg, "Player", "Hello");
// 2 choices from conditional, 2 common
if (TestEqual("Choice count when x=5", Dlg->GetNumberOfChoices(), 4))
{
TestTrue("Choose 1", Dlg->Choose(1));
TestDialogueText(this, "Text node", Dlg, "Player", "I took the 1.2 choice, random == 1");
}
// Now change to other conditional path
Dlg->SetVariableInt("x", 0);
Dlg->Restart(false);
TestDialogueText(this, "Start node", Dlg, "Player", "Hello");
if (TestEqual("Choice count when x=0", Dlg->GetNumberOfChoices(), 3))
{
TestTrue("Choose 1", Dlg->Choose(1));
TestDialogueText(this, "Text node", Dlg, "Player", "I took the alt 1.2 choice, random == 1");
}
Dlg->Restart(false);
TestDialogueText(this, "Start node", Dlg, "Player", "Hello");
if (TestEqual("Choice count when x=0", Dlg->GetNumberOfChoices(), 3))
{
TestTrue("Choose 1", Dlg->Choose(1));
TestDialogueText(this, "Text node", Dlg, "Player", "I took the alt 1.2 choice, random == 0");
}
Script->MarkAsGarbage();
return true;
}
UE_ENABLE_OPTIMIZATION

View File

@@ -0,0 +1,492 @@
#include "SUDSDialogue.h"
#include "SUDSLibrary.h"
#include "SUDSMessageLogger.h"
#include "SUDSScript.h"
#include "SUDSScriptImporter.h"
#include "SUDSSubsystem.h"
#include "TestUtils.h"
#include "Internationalization/Internationalization.h"
#include "Misc/AutomationTest.h"
UE_DISABLE_OPTIMIZATION
const FString SimpleRunnerInput = R"RAWSUD(
:start
Player: Hello there
NPC: Salutations fellow human
:choice
* Actually no
NPC: How rude, bye then
[goto end]
* Nested option
:nestedstart
NPC: Some nesting
* Actually bye
Player: Gotta go!
[go to goodbye]
* A fallthrough choice
NPC: This should fall through to latterhalf
* A goto choice
[goto latterhalf]
* Another option
Player: What now?
NPC: This is another fallthrough
:latterhalf
Player: This is the latter half of the discussion
NPC: Yep, sure is
* Go back to choice
NPC: Okay!
[goto choice]
* Return to the start
NPC: Gotcha
[goto start]
* Continue
Player: OK I'd like to carry on now
NPC: Right you are guv, falling through
:goodbye
NPC: Bye!
)RAWSUD";
const FString SetVariableRunnerInput = R"RAWSUD(
===
# Set some vars in header
# Text var with an existing localised ID
[set SpeakerName.Player "Protagonist"] @12345@
# Text var no localised ID
[set ValetName "Bob"]
// Also test with equals signs
[set SomeFloat = 12.5]
[set SomeCalculatedInt = (3 + 4) * 2]
[set SomeCalculatedBoolean = true or false]
// Test global vars
[set global.SomeGlobalInt 3]
[set global.SomeGlobalFloat = 12.5 * 3]
===
Player: Hello
[set SomeInt 99]
# Test that we can use variables in set and that ordering works
[set SomeOtherFloat {SomeFloat} + 10]
[set SomeFloat 43.754]
# Test global values
NPC: Wotcha
Player: Values are: {global.SomeGlobalInt}, {global.SomeGlobalFloat}, {global.GlobalIntSetOutside}
# Test that inserting a set node in between text and choice doesn't break link
[set SomeGender masculine]
* Choice 1
[set SomeBoolean True]
NPC: Truth
* Choice 2
NPC: Surprise
[set ValetName "Kate"]
[set SomeGender feminine]
[set someint 101]
Player: Well
)RAWSUD";
const FString FallthroughEdgeCaseInput = R"RAWSUD(
NPC: First line
* Option 1
NPC: Fallthrough via goto
[goto secondchoice]
* Option 2
NPC: Fallthrough implicitly
* Option 3
NPC: Fallthrough implicitly again
:secondchoice
* Fallthrough 1
NPC: text 1
* Fallthrough 2
NPC: text 2
)RAWSUD";
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestSimpleRunning,
"SUDSTest.TestSimpleRunning",
EAutomationTestFlags::EditorContext |
EAutomationTestFlags::ClientContext |
EAutomationTestFlags::ProductFilter)
bool FTestSimpleRunning::RunTest(const FString& Parameters)
{
FSUDSMessageLogger Logger(false);
FSUDSScriptImporter Importer;
TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(SimpleRunnerInput), SimpleRunnerInput.Len(), "SimpleRunnerInput", &Logger, true));
auto Script = NewObject<USUDSScript>(GetTransientPackage(), "Test");
const ScopedStringTableHolder StringTableHolder;
Importer.PopulateAsset(Script, StringTableHolder.StringTable);
// Script shouldn't be the owner of the dialogue but it's the only UObject we've got right now so why not
auto Dlg = USUDSLibrary::CreateDialogue(Script, Script);
Dlg->Start();
TestDialogueText(this, "First node", Dlg, "Player", "Hello there");
TestEqual("First node choices", Dlg->GetNumberOfChoices(), 1);
TestTrue("First node choice text", Dlg->GetChoiceText(0).IsEmpty());
TestTrue("Continue", Dlg->Continue());
TestDialogueText(this, "Node 2", Dlg, "NPC", "Salutations fellow human");
TestEqual("Node 2 choices", Dlg->GetNumberOfChoices(), 3);
TestEqual("Node 2 choice text 0", Dlg->GetChoiceText(0).ToString(), "Actually no");
TestEqual("Node 2 choice text 1", Dlg->GetChoiceText(1).ToString(), "Nested option");
TestEqual("Node 2 choice text 2", Dlg->GetChoiceText(2).ToString(), "Another option");
TestTrue("Choice 1", Dlg->Choose(0));
TestDialogueText(this, "Choice 1 Text", Dlg, "NPC", "How rude, bye then");
// Goes straight to end
TestFalse("Choice 1 Follow On", Dlg->Continue());
TestTrue("Should be at end", Dlg->IsEnded());
// Start again
Dlg->Restart();
TestDialogueText(this, "First node", Dlg, "Player", "Hello there");
TestEqual("First node choices", Dlg->GetNumberOfChoices(), 1);
TestTrue("First node choice text", Dlg->GetChoiceText(0).IsEmpty());
TestTrue("Continue", Dlg->Continue());
TestDialogueText(this, "Node 2", Dlg, "NPC", "Salutations fellow human");
// Confirm we previously chose 0
TestTrue("Choice 0 taken previously", Dlg->HasChoiceIndexBeenTakenPreviously(0));
TestTrue("Choice 2", Dlg->Choose(1));
TestDialogueText(this, "Choice 2 Text", Dlg, "NPC", "Some nesting");
TestEqual("Choice 2 nested choices", Dlg->GetNumberOfChoices(), 3);
TestEqual("Choice 2 nested choice text 0", Dlg->GetChoiceText(0).ToString(), "Actually bye");
TestEqual("Choice 2 nested choice text 1", Dlg->GetChoiceText(1).ToString(), "A fallthrough choice");
TestEqual("Choice 2 nested choice text 2", Dlg->GetChoiceText(2).ToString(), "A goto choice");
TestTrue("Nested choice made", Dlg->Choose(0));
TestDialogueText(this, "Nested choice made text", Dlg, "Player", "Gotta go!");
TestTrue("Nested choice follow On", Dlg->Continue());
TestDialogueText(this, "Nested choice follow on text", Dlg, "NPC", "Bye!");
TestFalse("Nested choice follow On 2", Dlg->Continue());
TestTrue("Should be at end", Dlg->IsEnded());
// Start again, this time from nested choice, and reset all state
Dlg->Restart(true, "nestedstart");
TestDialogueText(this, "nestedchoice restart Text", Dlg, "NPC", "Some nesting");
TestTrue("Nested choice made", Dlg->Choose(1));
TestDialogueText(this, "Nested choice 2 Text", Dlg, "NPC", "This should fall through to latterhalf");
TestTrue("Nested choice 2 follow On", Dlg->Continue());
// Should have fallen through
TestDialogueText(this, "Fallthrough Text", Dlg, "Player", "This is the latter half of the discussion");
TestTrue("Continue", Dlg->Continue());
TestDialogueText(this, "Fallthrough Text 2", Dlg, "NPC", "Yep, sure is");
TestEqual("Fallthrough choices", Dlg->GetNumberOfChoices(), 3);
TestEqual("Fallthrough choice text 0", Dlg->GetChoiceText(0).ToString(), "Go back to choice");
TestEqual("Fallthrough choice text 1", Dlg->GetChoiceText(1).ToString(), "Return to the start");
TestEqual("Fallthrough choice text 2", Dlg->GetChoiceText(2).ToString(), "Continue");
// Go back to choice
TestTrue("Fallthrough choice made", Dlg->Choose(0));
TestDialogueText(this, "Fallthrough Choice Text", Dlg, "NPC", "Okay!");
// The Goto choice should have collapsed the choices such that we can get them immediately
TestEqual("Fallthrough then goto choices", Dlg->GetNumberOfChoices(), 3);
TestEqual("Fallthrough then goto choice text 0", Dlg->GetChoiceText(0).ToString(), "Actually no");
TestEqual("Fallthrough then goto choice text 1", Dlg->GetChoiceText(1).ToString(), "Nested option");
TestEqual("Fallthrough then goto choice text 2", Dlg->GetChoiceText(2).ToString(), "Another option");
// Restart to test another path
Dlg->Restart(true, "nestedstart");
TestDialogueText(this, "nestedchoice restart Text", Dlg, "NPC", "Some nesting");
TestTrue("Nested choice made", Dlg->Choose(2));
// This should be a direct goto to latterhalf
TestDialogueText(this, "Direct goto", Dlg, "Player", "This is the latter half of the discussion");
Dlg->Restart(true);
TestTrue("Continue", Dlg->Continue());
TestTrue("Choice 3", Dlg->Choose(2));
TestDialogueText(this, "Choice 3 Text", Dlg, "Player", "What now?");
TestTrue("Continue", Dlg->Continue());
TestDialogueText(this, "Choice 3 Text 2", Dlg, "NPC", "This is another fallthrough");
TestTrue("Continue", Dlg->Continue());
// Should have fallen through
TestDialogueText(this, "Direct goto", Dlg, "Player", "This is the latter half of the discussion");
Script->MarkAsGarbage();
return true;
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestSetVariableRunning,
"SUDSTest.TestSetVariableRunning",
EAutomationTestFlags::EditorContext |
EAutomationTestFlags::ClientContext |
EAutomationTestFlags::ProductFilter)
bool FTestSetVariableRunning::RunTest(const FString& Parameters)
{
// Number and plural formatting are locale-specific, so we must set it to a predefined value to produce the expected output
FInternationalization::FCultureStateSnapshot CultureStateSnapshot;
FInternationalization::Get().BackupCultureState(CultureStateSnapshot);
FInternationalization::Get().SetCurrentCulture(TEXT("en-US"));
ON_SCOPE_EXIT
{
FInternationalization::Get().RestoreCultureState(CultureStateSnapshot);
};
FSUDSMessageLogger Logger(false);
FSUDSScriptImporter Importer;
TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(SetVariableRunnerInput), SetVariableRunnerInput.Len(), "SetVariableRunnerInput", &Logger, true));
auto Script = NewObject<USUDSScript>(GetTransientPackage(), "Test");
const ScopedStringTableHolder StringTableHolder;
Importer.PopulateAsset(Script, StringTableHolder.StringTable);
// We need to use dummy global vars since subsystem doesn't exist
USUDSSubsystem::Test_DummyGlobalVariables.Empty();
USUDSSubsystem::Test_DummyGlobalVariables.Add("GlobalIntSetOutside", 245);
// Script shouldn't be the owner of the dialogue but it's the only UObject we've got right now so why not
auto Dlg = USUDSLibrary::CreateDialogue(Script, Script);
Dlg->Start();
// Check headers have run & initial variables are set
TestEqual("Header: Player name", Dlg->GetVariableText("SpeakerName.Player").ToString(), "Protagonist");
TestEqual("Header: Valet name", Dlg->GetVariableText("ValetName").ToString(), "Bob");
TestEqual("Header: Some float", Dlg->GetVariableFloat("SomeFloat"), 12.5f);
// Check initial values
TestEqual("Initial: Some int", Dlg->GetVariableInt("SomeInt"), 0);
TestEqual("Initial: Some boolean", Dlg->GetVariableBoolean("SomeBoolean"), false);
TestEqual("Initial: Some gender", Dlg->GetVariableGender("SomeGender"), ETextGender::Neuter);
TestEqual("Initial: calculated int", Dlg->GetVariableInt("SomeCalculatedInt"), 14);
TestTrue("Initial: calculated bool", Dlg->GetVariableBoolean("SomeCalculatedBoolean"));
// Test globals
TestFalse("Test not setting local var", Dlg->IsVariableSet("global.SomeGlobalInt"));
TestFalse("Test not setting local var", Dlg->IsVariableSet("global.SomeGlobalFloat"));
TestTrue("Test setting global var", USUDSSubsystem::Test_DummyGlobalVariables.Contains("SomeGlobalInt"));
TestTrue("Test setting global var", USUDSSubsystem::Test_DummyGlobalVariables.Contains("SomeGlobalFloat"));
TestDialogueText(this, "Node 1", Dlg, "Player", "Hello");
TestTrue("Continue", Dlg->Continue());
// Set node should have run
TestEqual("Initial: Some int", Dlg->GetVariableInt("SomeInt"), 99);
TestDialogueText(this, "Node 2", Dlg, "NPC", "Wotcha");
// Test that setting a new variable from another variable + 10 worked
TestEqual("Some copied float", Dlg->GetVariableFloat("SomeOtherFloat"), 22.5f);
TestEqual("Original float", Dlg->GetVariableFloat("SomeFloat"), 43.754f);
// Test globals
TestTrue("Continue", Dlg->Continue());
TestDialogueText(this, "Global text", Dlg, "Player", "Values are: 3, 37.5, 245");
TestEqual("Choices count", Dlg->GetNumberOfChoices(), 2);
TestEqual("Choice 1 text", Dlg->GetChoiceText(0).ToString(), "Choice 1");
TestEqual("Choice 2 text", Dlg->GetChoiceText(1).ToString(), "Choice 2");
TestTrue("Choose 1", Dlg->Choose(0));
TestEqual("Gender should be set", Dlg->GetVariableGender("SomeGender"), ETextGender::Masculine);
TestEqual("Some boolean should be set", Dlg->GetVariableBoolean("SomeBoolean"), true);
TestEqual("Valet name should not have changed", Dlg->GetVariableText("ValetName").ToString(), "Bob");
TestEqual("Gender should not have changed", Dlg->GetVariableGender("SomeGender"), ETextGender::Masculine);
TestDialogueText(this, "Choice end text", Dlg, "NPC", "Truth");
TestTrue("Continue", Dlg->Continue());
TestDialogueText(this, "Final node", Dlg, "Player", "Well");
TestFalse("Continue", Dlg->Continue());
TestTrue("At end", Dlg->IsEnded());
// Restart and DON'T reset state
Dlg->Restart(false);
// Variables should be the same
// Except for the headers, which will have run again
TestEqual("Player name should have been set again", Dlg->GetVariableText("SpeakerName.Player").ToString(), "Protagonist");
TestEqual("Valet name should have been set again", Dlg->GetVariableText("ValetName").ToString(), "Bob");
TestEqual("Some float should have been set again", Dlg->GetVariableFloat("SomeFloat"), 12.5f);
TestEqual("Int should still be set", Dlg->GetVariableInt("SomeInt"), 99);
TestEqual("Gender should still be set", Dlg->GetVariableGender("SomeGender"), ETextGender::Masculine);
TestEqual("Some boolean should still be set", Dlg->GetVariableBoolean("SomeBoolean"), true);
// Restart and DO reset state
Dlg->Restart(true);
TestEqual("Player name should have been set again", Dlg->GetVariableText("SpeakerName.Player").ToString(), "Protagonist");
TestEqual("Valet name should have been set again", Dlg->GetVariableText("ValetName").ToString(), "Bob");
TestEqual("Some float should have been set again", Dlg->GetVariableFloat("SomeFloat"), 12.5f);
TestEqual("Int should have been reset", Dlg->GetVariableInt("SomeInt"), 0);
TestEqual("Gender should have been reset", Dlg->GetVariableGender("SomeGender"), ETextGender::Neuter);
TestEqual("Some boolean should have been reset", Dlg->GetVariableBoolean("SomeBoolean"), false);
// Try the other path
TestTrue("Continue", Dlg->Continue());
TestTrue("Continue", Dlg->Continue());
TestTrue("Choose 2", Dlg->Choose(1));
TestDialogueText(this, "Choice 2 text", Dlg, "NPC", "Surprise");
TestEqual("Gender should not be changed yet", Dlg->GetVariableGender("SomeGender"), ETextGender::Masculine);
TestEqual("Valet name should not be changed yet", Dlg->GetVariableText("ValetName").ToString(), "Bob");
TestTrue("Continue", Dlg->Continue());
TestDialogueText(this, "Final node", Dlg, "Player", "Well");
TestEqual("Gender should have changed", Dlg->GetVariableGender("SomeGender"), ETextGender::Feminine);
TestEqual("Valet name should have changed", Dlg->GetVariableText("ValetName").ToString(), "Kate");
TestEqual("Case insensitive set test", Dlg->GetVariableInt("SomeInt"), 101);
TestFalse("Continue", Dlg->Continue());
TestTrue("At end", Dlg->IsEnded());
USUDSSubsystem::Test_DummyGlobalVariables.Empty();
Script->MarkAsGarbage();
return true;
}
const FString SpeakerNamesInput = R"RAWSUD(
===
[set SpeakerName.Player "Protagonist"]
[set SpeakerName.NPC "Just Some Guy"]
===
:start
Player: Hello there
NPC: Salutations fellow human
ThirdGuy: Sup
[set SpeakerName.NPC "Actually A Villain"]
NPC: Aha!
)RAWSUD";
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestSpeakerNames,
"SUDSTest.TestSpeakerNames",
EAutomationTestFlags::EditorContext |
EAutomationTestFlags::ClientContext |
EAutomationTestFlags::ProductFilter)
bool FTestSpeakerNames::RunTest(const FString& Parameters)
{
FSUDSMessageLogger Logger(false);
FSUDSScriptImporter Importer;
TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(SpeakerNamesInput), SpeakerNamesInput.Len(), "SpeakerNamesInput", &Logger, true));
auto Script = NewObject<USUDSScript>(GetTransientPackage(), "Test");
const ScopedStringTableHolder StringTableHolder;
Importer.PopulateAsset(Script, StringTableHolder.StringTable);
// Script shouldn't be the owner of the dialogue but it's the only UObject we've got right now so why not
auto Dlg = USUDSLibrary::CreateDialogue(Script, Script);
Dlg->Start();
// Speaker ID test
TestDialogueText(this, "Initial text", Dlg, "Player", "Hello there");
TestEqual("Initial player speaker name", Dlg->GetSpeakerDisplayName().ToString(), "Protagonist");
TestTrue("Continue", Dlg->Continue());
TestDialogueText(this, "Text 2", Dlg, "NPC", "Salutations fellow human");
TestEqual("Initial NPC speaker name", Dlg->GetSpeakerDisplayName().ToString(), "Just Some Guy");
TestTrue("Continue", Dlg->Continue());
TestDialogueText(this, "Text 3", Dlg, "ThirdGuy", "Sup");
TestEqual("Initial ThirdGuy speaker name", Dlg->GetSpeakerDisplayName().ToString(), "ThirdGuy");
TestTrue("Continue", Dlg->Continue());
TestDialogueText(this, "Text 4", Dlg, "NPC", "Aha!");
TestEqual("NPC speaker name should have changed", Dlg->GetSpeakerDisplayName().ToString(), "Actually A Villain");
Script->MarkAsGarbage();
return true;
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestFallthroughEdgeCase,
"SUDSTest.TestFallthroughEdgeCase",
EAutomationTestFlags::EditorContext |
EAutomationTestFlags::ClientContext |
EAutomationTestFlags::ProductFilter)
bool FTestFallthroughEdgeCase::RunTest(const FString& Parameters)
{
FSUDSMessageLogger Logger(false);
FSUDSScriptImporter Importer;
TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(FallthroughEdgeCaseInput), FallthroughEdgeCaseInput.Len(), "FallthroughEdgeCaseInput", &Logger, true));
auto Script = NewObject<USUDSScript>(GetTransientPackage(), "Test");
const ScopedStringTableHolder StringTableHolder;
Importer.PopulateAsset(Script, StringTableHolder.StringTable);
// Script shouldn't be the owner of the dialogue but it's the only UObject we've got right now so why not
auto Dlg = USUDSLibrary::CreateDialogue(Script, Script);
Dlg->Start();
TestDialogueText(this, "Initial text", Dlg, "NPC", "First line");
TestEqual("Initial num choices", Dlg->GetNumberOfChoices(), 3);
TestTrue("Choice 1", Dlg->Choose(0));
TestDialogueText(this, "Second text", Dlg, "NPC", "Fallthrough via goto");
if (TestEqual("Second num choices", Dlg->GetNumberOfChoices(), 2))
{
TestEqual("Choice text", Dlg->GetChoiceText(0).ToString(), "Fallthrough 1");
TestEqual("Choice text", Dlg->GetChoiceText(1).ToString(), "Fallthrough 2");
}
// Now prove that implicit fallthrough works in middle option
Dlg->Restart();
TestTrue("Choice 2", Dlg->Choose(1));
TestDialogueText(this, "Second text", Dlg, "NPC", "Fallthrough implicitly");
if (TestEqual("Second num choices", Dlg->GetNumberOfChoices(), 2))
{
TestEqual("Choice text", Dlg->GetChoiceText(0).ToString(), "Fallthrough 1");
TestEqual("Choice text", Dlg->GetChoiceText(1).ToString(), "Fallthrough 2");
}
// Now prove that implicit fallthrough works in last option
Dlg->Restart();
TestTrue("Choice 2", Dlg->Choose(2));
TestDialogueText(this, "Second text", Dlg, "NPC", "Fallthrough implicitly again");
if (TestEqual("Second num choices", Dlg->GetNumberOfChoices(), 2))
{
TestEqual("Choice text", Dlg->GetChoiceText(0).ToString(), "Fallthrough 1");
TestEqual("Choice text", Dlg->GetChoiceText(1).ToString(), "Fallthrough 2");
}
Script->MarkAsGarbage();
return true;
}
const FString LinesBetweenTextAndChoiceInput = R"RAWSUD(
NPC: Hello
NPC: Here's some choices
[event SomeEvent]
* Option 1
NPC: This is option 1
* Option 2
NPC: This is option 2
)RAWSUD";
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestLinesBetweenTextAndChoice,
"SUDSTest.TestLinesBetweenTextAndChoiceInput",
EAutomationTestFlags::EditorContext |
EAutomationTestFlags::ClientContext |
EAutomationTestFlags::ProductFilter)
bool FTestLinesBetweenTextAndChoice::RunTest(const FString& Parameters)
{
FSUDSMessageLogger Logger(false);
FSUDSScriptImporter Importer;
TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(LinesBetweenTextAndChoiceInput), LinesBetweenTextAndChoiceInput.Len(), "LinesBetweenTextAndChoiceInput", &Logger, true));
auto Script = NewObject<USUDSScript>(GetTransientPackage(), "Test");
const ScopedStringTableHolder StringTableHolder;
Importer.PopulateAsset(Script, StringTableHolder.StringTable);
// Script shouldn't be the owner of the dialogue but it's the only UObject we've got right now so why not
auto Dlg = USUDSLibrary::CreateDialogue(Script, Script);
Dlg->Start();
TestDialogueText(this, "Initial text", Dlg, "NPC", "Hello");
TestTrue("Continue", Dlg->Continue());
TestDialogueText(this, "Initial text", Dlg, "NPC", "Here's some choices");
TestEqual("Initial num choices", Dlg->GetNumberOfChoices(), 2);
return true;
}
UE_ENABLE_OPTIMIZATION

View File

@@ -0,0 +1,146 @@
#include "SUDSDialogue.h"
#include "SUDSLibrary.h"
#include "SUDSMessageLogger.h"
#include "SUDSScript.h"
#include "SUDSScriptImporter.h"
#include "TestUtils.h"
#include "Misc/AutomationTest.h"
UE_DISABLE_OPTIMIZATION
const FString SaveStateInput = R"RAWSUD(
===
# To confirm that when resuming we don't run headers again
[set y = -2.4]
===
[if {alreadyvisited}]
[goto secondvisit]
[endif]
NPC: Hello
* First choice
Player: I took the 1.1 choice
[if {x} > 0]
* Second choice (conditional)
Player: I took the 1.2 choice
* Third choice (conditional)
Player: I took the 1.3 choice
[else]
* Second Alt Choice
Player: I took the alt 1.2 choice
[endif]
* Common last choice
Player: I took the 1.4 choice
[goto goodbye]
:secondvisit
NPC: Hello again you!
[if {y} < 0]
Player: Y is less than 0
NPC: How interesting
* You don't sound that interested
NPC: Well I was trying to be nice
[if {x} == 0]
* Also x is zero
NPC: Fascinating
[endif]
* Well, better be off
[goto goodbye]
[elseif {y} == 0]
Player: Y is zero
NPC: So is my interest level
Player: Rude
[else]
Player: Who knows what Y is anyway
[if {ponderous}]
* Who knows what anything is?
NPC: Get out
[endif]
[if {y} > 4.99]
* It's more than I can count on one hand
NPC: Well, that helps
[else]
* It's kind of small though
NPC: Much like your brain
[endif]
* I'm done with this
[endif]
Player: This is some fallthrough text
:goodbye
NPC: Bye
)RAWSUD";
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestSaveState,
"SUDSTest.TestSaveState",
EAutomationTestFlags::EditorContext |
EAutomationTestFlags::ClientContext |
EAutomationTestFlags::ProductFilter)
bool FTestSaveState::RunTest(const FString& Parameters)
{
FSUDSMessageLogger Logger(false);
FSUDSScriptImporter Importer;
TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(SaveStateInput), SaveStateInput.Len(), "SaveStateInput", &Logger, true));
auto Script = NewObject<USUDSScript>(GetTransientPackage(), "Test");
const ScopedStringTableHolder StringTableHolder;
Importer.PopulateAsset(Script, StringTableHolder.StringTable);
// Script shouldn't be the owner of the dialogue but it's the only UObject we've got right now so why not
auto Dlg = USUDSLibrary::CreateDialogue(Script, Script);
// Set x before start
// No point settings y because that gets changed by headers
Dlg->SetVariableInt("x", 5);
Dlg->Start();
TestDialogueText(this, "Text Node", Dlg, "NPC", "Hello");
if (!TestEqual("Num choices", Dlg->GetNumberOfChoices(), 4))
return true;
TestEqual("ChoiceText", Dlg->GetChoiceText(0).ToString(), "First choice");
TestEqual("ChoiceText", Dlg->GetChoiceText(1).ToString(), "Second choice (conditional)");
TestEqual("ChoiceText", Dlg->GetChoiceText(2).ToString(), "Third choice (conditional)");
TestEqual("ChoiceText", Dlg->GetChoiceText(3).ToString(), "Common last choice");
TestTrue("Choose", Dlg->Choose(2));
TestDialogueText(this, "Text node", Dlg, "Player", "I took the 1.3 choice");
// Set value of y to test it's retained, and not reset by running headers
Dlg->SetVariableFloat("y", 23.5f);
// Save it here
auto SaveState = Dlg->GetSavedState();
// Re-construct the dialogue & restore
auto Dlg2 = USUDSLibrary::CreateDialogue(Script, Script);
Dlg2->RestoreSavedState(SaveState);
// We should be back at the same point
TestDialogueText(this, "Text node", Dlg2, "Player", "I took the 1.3 choice");
// Check vars
TestEqual("x value", Dlg2->GetVariableInt("x"), 5);
TestEqual("y value", Dlg2->GetVariableFloat("y"), 23.5f);
TestTrue("Continue", Dlg2->Continue());
TestDialogueText(this, "Text node", Dlg2, "NPC", "Bye");
// Restart to check choices were remembered
Dlg2->Restart();
TestDialogueText(this, "Text Node", Dlg2, "NPC", "Hello");
TestFalse("Choice not taken", Dlg2->HasChoiceIndexBeenTakenPreviously(0));
TestFalse("Choice not taken", Dlg2->HasChoiceIndexBeenTakenPreviously(1));
TestTrue("Choice not taken", Dlg2->HasChoiceIndexBeenTakenPreviously(2));
TestFalse("Choice not taken", Dlg2->HasChoiceIndexBeenTakenPreviously(3));
Script->MarkAsGarbage();
return true;
}
UE_ENABLE_OPTIMIZATION

View File

@@ -0,0 +1,124 @@
#include "SUDSDialogue.h"
#include "SUDSLibrary.h"
#include "SUDSMessageLogger.h"
#include "SUDSScript.h"
#include "SUDSScriptImporter.h"
#include "TestUtils.h"
#include "Misc/AutomationTest.h"
UE_DISABLE_OPTIMIZATION
// Assign known keys to the strings so we can detect
const FString UserMetadataInput = R"RAWSUD(
# No metadata
Player: Good day sir
#% IsGreeting = true
NPC: Salutations to you too
#% IntValue = 1
#% Politeness 3.142
Player: What are we doing today?
# Test a variable-derived, expression metadata
[set IntVariable 2]
#% IntValue = {IntVariable} + 3
#% LookMumNoEquals `OK_Dear`
NPC: Looks like we're doing some testing. How about a choice?
#% RequirePoshness = 10
* Choice 1
Player: Capital, old chap
#% RequirePoshness = 15
#% RequireTopHat = true
* Choice 2
Player: Top hole, dear boy
* Choice 3
Player: OK then
)RAWSUD";
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTestUserMetadata,
"SUDSTest.TestUserMetadata",
EAutomationTestFlags::EditorContext |
EAutomationTestFlags::ClientContext |
EAutomationTestFlags::ProductFilter)
bool FTestUserMetadata::RunTest(const FString& Parameters)
{
FSUDSMessageLogger Logger(false);
FSUDSScriptImporter Importer;
TestTrue("Import should succeed", Importer.ImportFromBuffer(GetData(UserMetadataInput), UserMetadataInput.Len(), "UserMetadataInput", &Logger, true));
auto Script = NewObject<USUDSScript>(GetTransientPackage(), "Test");
const ScopedStringTableHolder StringTableHolder;
Importer.PopulateAsset(Script, StringTableHolder.StringTable);
// Script shouldn't be the owner of the dialogue but it's the only UObject we've got right now so why not
auto Dlg = USUDSLibrary::CreateDialogue(Script, Script);
Dlg->Start();
TestDialogueText(this, "First node", Dlg, "Player", "Good day sir");
TestEqual("Check metadata empty", Dlg->GetAllSpeakerLineUserMetadata().Num(), 0);
Dlg->Continue();
TestDialogueText(this, "Next", Dlg, "NPC", "Salutations to you too");
FSUDSValue Actual = Dlg->GetSpeakerLineUserMetadata(FName("IsGreeting"));
if (TestTrue("Boolean test", Actual.GetType() == ESUDSValueType::Boolean))
{
TestEqual("Boolean test", Actual.GetBooleanValue(), true);
}
Dlg->Continue();
TestDialogueText(this, "Next", Dlg, "Player", "What are we doing today?");
Actual = Dlg->GetSpeakerLineUserMetadata(FName("IntValue"));
if (TestTrue("Int test", Actual.GetType() == ESUDSValueType::Int))
{
TestEqual("Int test", Actual.GetIntValue(), 1);
}
Actual = Dlg->GetSpeakerLineUserMetadata(FName("Politeness"));
if (TestTrue("Float test", Actual.GetType() == ESUDSValueType::Float))
{
TestEqual("Float test", Actual.GetFloatValue(), 3.142f);
}
Dlg->Continue();
TestDialogueText(this, "Next", Dlg, "NPC", "Looks like we're doing some testing. How about a choice?");
Actual = Dlg->GetSpeakerLineUserMetadata(FName("IntValue"));
if (TestTrue("Expression test", Actual.GetType() == ESUDSValueType::Int))
{
TestEqual("Expression test", Actual.GetIntValue(), 5);
}
Actual = Dlg->GetSpeakerLineUserMetadata(FName("LookMumNoEquals"));
if (TestTrue("Name & no equals test", Actual.GetType() == ESUDSValueType::Name))
{
TestEqual("Name & no equals test", Actual.GetNameValue(), FName("OK_Dear"));
}
if (TestEqual("Choice count", Dlg->GetNumberOfChoices(), 3))
{
Actual = Dlg->GetChoiceUserMetadata(0, FName("RequirePoshness"));
if (TestTrue("Choice 0 test", Actual.GetType() == ESUDSValueType::Int))
{
TestEqual("Choice 0 test", Actual.GetIntValue(), 10);
}
Actual = Dlg->GetChoiceUserMetadata(1, FName("RequirePoshness"));
if (TestTrue("Choice 1 test A", Actual.GetType() == ESUDSValueType::Int))
{
TestEqual("Choice 1 test A", Actual.GetIntValue(), 15);
}
Actual = Dlg->GetChoiceUserMetadata(1, FName("RequireTopHat"));
if (TestTrue("Choice 1 test B", Actual.GetType() == ESUDSValueType::Boolean))
{
TestEqual("Choice 1 test B", Actual.GetBooleanValue(), true);
}
auto Choice1All = Dlg->GetAllChoiceUserMetadata(1);
TestEqual("Choice 1 test count", Choice1All.Num(), 2);
TestEqual("Choice 2 test", Dlg->GetAllChoiceUserMetadata(2).Num(), 0);
}
Script->MarkAsGarbage();
return true;
}
UE_ENABLE_OPTIMIZATION

View File

@@ -0,0 +1,297 @@
#pragma once
#include "SUDSDialogue.h"
#include "SUDSScriptNode.h"
#include "SUDSScriptNodeText.h"
#include "Internationalization/StringTable.h"
#include "Internationalization/StringTableRegistry.h"
#include "Misc/AutomationTest.h"
#include "UObject/Package.h"
FORCEINLINE void TestDialogueText(FAutomationTestBase* T, const FString& NameForTest, USUDSDialogue* D, const FString& SpeakerID, const FString& Text)
{
T->TestEqual(NameForTest, D->GetSpeakerID(), SpeakerID);
T->TestEqual(NameForTest, D->GetText().ToString(), Text);
}
FORCEINLINE bool TestParsedText(FAutomationTestBase* T, const FString& NameForTest, const FSUDSParsedNode* Node, const FString& Speaker, const FString& Text)
{
if (T->TestNotNull(NameForTest, Node))
{
T->TestEqual(NameForTest, Node->NodeType, ESUDSParsedNodeType::Text);
T->TestEqual(NameForTest, Node->Identifier, Speaker);
T->TestEqual(NameForTest, Node->Text, Text);
return true;
}
return false;
}
FORCEINLINE bool TestArgValue(FAutomationTestBase* T, const FString& NameForTest, const FSUDSValue& Actual, int Expected)
{
if (T->TestEqual(NameForTest, Actual.GetType(), ESUDSValueType::Int))
{
return T->TestEqual(NameForTest, Actual.GetIntValue(), Expected);
}
return false;
}
FORCEINLINE bool TestArgValue(FAutomationTestBase* T, const FString& NameForTest, const FSUDSValue& Actual, float Expected)
{
if (T->TestEqual(NameForTest, Actual.GetType(), ESUDSValueType::Float))
{
return T->TestEqual(NameForTest, Actual.GetFloatValue(), Expected);
}
return false;
}
FORCEINLINE bool TestArgValue(FAutomationTestBase* T, const FString& NameForTest, const FSUDSValue& Actual, ETextGender Expected)
{
if (T->TestEqual(NameForTest, Actual.GetType(), ESUDSValueType::Gender))
{
return T->TestEqual(NameForTest, Actual.GetGenderValue(), Expected);
}
return false;
}
FORCEINLINE bool TestArgValue(FAutomationTestBase* T, const FString& NameForTest, const FSUDSValue& Actual, const FString& Expected)
{
if (T->TestEqual(NameForTest, Actual.GetType(), ESUDSValueType::Text))
{
return T->TestEqual(NameForTest, Actual.GetTextValue().ToString(), Expected);
}
return false;
}
template <typename V>
FORCEINLINE bool TestParsedSetLiteral(FAutomationTestBase* T, const FString& NameForTest, const FSUDSParsedNode* Node, const FString& VarName, V Literal)
{
if (T->TestNotNull(NameForTest, Node))
{
T->TestEqual(NameForTest, Node->NodeType, ESUDSParsedNodeType::SetVariable);
T->TestEqual(NameForTest, Node->Identifier, VarName);
if (T->TestTrue(NameForTest, Node->Expression.IsLiteral()))
{
TestArgValue(T, NameForTest,Node->Expression.GetLiteralValue(), Literal);
}
return true;
}
return false;
}
// Explicit bool version of the above since otherwise it gets converted to int & fails
FORCEINLINE bool TestParsedSetLiteral(FAutomationTestBase* T, const FString& NameForTest, const FSUDSParsedNode* Node, const FString& VarName, bool Literal)
{
if (T->TestNotNull(NameForTest, Node))
{
T->TestEqual(NameForTest, Node->NodeType, ESUDSParsedNodeType::SetVariable);
T->TestEqual(NameForTest, Node->Identifier, VarName);
if (T->TestTrue(NameForTest, Node->Expression.IsLiteral()))
{
T->TestEqual(NameForTest,Node->Expression.GetBooleanLiteralValue(), Literal);
}
return true;
}
return false;
}
FORCEINLINE bool TestParsedSetLiteral(FAutomationTestBase* T, const FString& NameForTest, const FSUDSParsedNode* Node, const FString& VarName, const FName& Name)
{
if (T->TestNotNull(NameForTest, Node))
{
T->TestEqual(NameForTest, Node->NodeType, ESUDSParsedNodeType::SetVariable);
T->TestEqual(NameForTest, Node->Identifier, VarName);
if (T->TestTrue(NameForTest, Node->Expression.IsLiteral()))
{
T->TestEqual(NameForTest,Node->Expression.GetNameLiteralValue(), Name);
}
return true;
}
return false;
}
FORCEINLINE bool TestGetParsedNextNode(FAutomationTestBase* T, const FString& NameForTest, const FSUDSParsedNode* Node, FSUDSScriptImporter& Importer, bool bIsHeader, const FSUDSParsedNode** OutNextNode)
{
if (T->TestNotNull(NameForTest, Node))
{
if (T->TestEqual(NameForTest, Node->Edges.Num(), 1))
{
const int NextNodeIdx = Node->Edges[0].TargetNodeIdx;
*OutNextNode = bIsHeader ? Importer.GetHeaderNode(NextNodeIdx) : Importer.GetNode(NextNodeIdx);
return true;
}
}
*OutNextNode = nullptr;
return false;
}
FORCEINLINE bool TestParsedChoice(FAutomationTestBase* T, const FString& NameForTest, const FSUDSParsedNode* Node, int ExpectedNumChoices)
{
if (T->TestNotNull(NameForTest, Node))
{
T->TestEqual(NameForTest, Node->NodeType, ESUDSParsedNodeType::Choice);
T->TestEqual(NameForTest, Node->Edges.Num(), ExpectedNumChoices);
return true;
}
return false;
}
FORCEINLINE bool TestParsedSelect(FAutomationTestBase* T, const FString& NameForTest, const FSUDSParsedNode* Node, int ExpectedNumEdges)
{
if (T->TestNotNull(NameForTest, Node))
{
T->TestEqual(NameForTest, Node->NodeType, ESUDSParsedNodeType::Select);
T->TestEqual(NameForTest, Node->Edges.Num(), ExpectedNumEdges);
return true;
}
return false;
}
FORCEINLINE bool TestParsedChoiceEdge(FAutomationTestBase* T, const FString& NameForTest, const FSUDSParsedNode* Node, int EdgeIndex, const FString& Text, FSUDSScriptImporter& Importer, const FSUDSParsedNode** OutNode)
{
*OutNode = nullptr;
if (Node && Node->Edges.Num() > EdgeIndex)
{
auto& Edge = Node->Edges[EdgeIndex];
T->TestEqual(NameForTest, Edge.Text, Text);
const int Idx = Edge.TargetNodeIdx;
*OutNode = Importer.GetNode(Idx);
return true;
}
return false;
}
FORCEINLINE bool TestParsedSelectEdge(FAutomationTestBase* T, const FString& NameForTest, const FSUDSParsedNode* Node, int EdgeIndex, const FString& ConditionStr, FSUDSScriptImporter& Importer, const FSUDSParsedNode** OutNode)
{
*OutNode = nullptr;
if (Node && Node->Edges.Num() > EdgeIndex)
{
auto& Edge = Node->Edges[EdgeIndex];
T->TestEqual(NameForTest, Edge.ConditionExpression.GetSourceString(), ConditionStr);
const int Idx = Edge.TargetNodeIdx;
*OutNode = Importer.GetNode(Idx);
return true;
}
return false;
}
FORCEINLINE bool TestParsedGoto(FAutomationTestBase* T, const FString& NameForTest, const FSUDSParsedNode* Node, FSUDSScriptImporter& Importer, const FSUDSParsedNode** OutNode)
{
if (T->TestNotNull(NameForTest, Node))
{
T->TestEqual(NameForTest, Node->NodeType, ESUDSParsedNodeType::Goto);
const int Target = Importer.GetGotoTargetNodeIndex(Node->Identifier);
*OutNode = Importer.GetNode(Target);
return true;
}
return false;
}
FORCEINLINE bool TestTextNode(FAutomationTestBase* T, const FString& NameForTest, const USUDSScriptNode* Node, const FString& Speaker, const FString& Text)
{
if (T->TestNotNull(NameForTest, Node))
{
T->TestEqual(NameForTest, Node->GetNodeType(), ESUDSScriptNodeType::Text);
if (auto TextNode = Cast<USUDSScriptNodeText>(Node))
{
T->TestEqual(NameForTest, TextNode->GetSpeakerID(), Speaker);
T->TestEqual(NameForTest, TextNode->GetText().ToString(), Text);
}
return true;
}
return false;
}
FORCEINLINE bool TestEdge(FAutomationTestBase* T, const FString& NameForTest, USUDSScriptNode* Node, int EdgeIndex, USUDSScriptNode** OutNode)
{
*OutNode = nullptr;
if (Node && Node->GetEdgeCount() > EdgeIndex)
{
if (auto Edge = Node->GetEdge(EdgeIndex))
{
*OutNode = Edge->GetTargetNode().Get();
return T->TestNotNull(NameForTest, *OutNode);
}
}
return false;
}
FORCEINLINE bool TestChoiceNode(FAutomationTestBase* T, const FString& NameForTest, const USUDSScriptNode* Node, int NumChoices)
{
if (T->TestNotNull(NameForTest, Node))
{
T->TestEqual(NameForTest, Node->GetNodeType(), ESUDSScriptNodeType::Choice);
return T->TestEqual(NameForTest, Node->GetEdgeCount(), NumChoices);
}
return false;
}
FORCEINLINE bool TestSelectNode(FAutomationTestBase* T, const FString& NameForTest, const USUDSScriptNode* Node, int NumEdges)
{
if (T->TestNotNull(NameForTest, Node))
{
T->TestEqual(NameForTest, Node->GetNodeType(), ESUDSScriptNodeType::Select);
return T->TestEqual(NameForTest, Node->GetEdgeCount(), NumEdges);
}
return false;
}
FORCEINLINE bool TestChoiceEdge(FAutomationTestBase* T, const FString& NameForTest, USUDSScriptNode* Node, int EdgeIndex, const FString& Text, USUDSScriptNode** OutNode)
{
*OutNode = nullptr;
if (Node && Node->GetEdgeCount() > EdgeIndex)
{
if (auto Edge = Node->GetEdge(EdgeIndex))
{
T->TestEqual(NameForTest, Edge->GetText().ToString(), Text);
*OutNode = Edge->GetTargetNode().Get();
return T->TestNotNull(NameForTest, *OutNode);
}
}
return false;
}
FORCEINLINE bool TestSelectEdge(FAutomationTestBase* T, const FString& NameForTest, USUDSScriptNode* Node, int EdgeIndex, const FString& ConditionStr, USUDSScriptNode** OutNode)
{
*OutNode = nullptr;
if (Node && Node->GetEdgeCount() > EdgeIndex)
{
if (auto Edge = Node->GetEdge(EdgeIndex))
{
T->TestEqual(NameForTest, Edge->GetCondition().GetSourceString(), ConditionStr);
*OutNode = Edge->GetTargetNode().Get();
return T->TestNotNull(NameForTest, *OutNode);
}
}
return false;
}
// Helper to provide a string table just in scope
struct ScopedStringTableHolder
{
public:
UStringTable* StringTable;
ScopedStringTableHolder()
{
StringTable = NewObject<UStringTable>(GetTransientPackage(), "TestStrings");
}
~ScopedStringTableHolder()
{
// Tidy up string table
// UStringTable constructor registered this table
FStringTableRegistry::Get().UnregisterStringTable(StringTable->GetStringTableId());
}
};

View File

@@ -0,0 +1,30 @@
// Copyright 1998-2019 Epic Games, Inc. All Rights Reserved.
using UnrealBuildTool;
public class SUDSTest : ModuleRules
{
public SUDSTest(ReadOnlyTargetRules Target) : base(Target)
{
PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
PrivateDependencyModuleNames.AddRange(
new string[]
{
"Core",
"CoreUObject",
"Engine",
"SUDS",
"SUDSEditor"
}
);
// Uncomment if you are using Slate UI
// PrivateDependencyModuleNames.AddRange(new string[] { "Slate", "SlateCore" });
// Uncomment if you are using online features
// PrivateDependencyModuleNames.Add("OnlineSubsystem");
// To include OnlineSubsystemSteam, add it to the plugins section in your uproject file with the Enabled attribute set to true
}
}