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

Binary file not shown.

Binary file not shown.

18
Plugins/SUDS/License.txt Normal file
View File

@@ -0,0 +1,18 @@
The MIT License (MIT) Copyright © 2022 Steve Streeting
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the “Software”), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

Binary file not shown.

Binary file not shown.

33
Plugins/SUDS/SUDS.uplugin Normal file
View File

@@ -0,0 +1,33 @@
{
"FileVersion": 3,
"Version": 1,
"VersionName": "1.0",
"FriendlyName": "SUDS",
"Description": "Steve's Unreal Dialogue System",
"Category": "Other",
"CreatedBy": "Steve Streeting",
"CreatedByURL": "",
"DocsURL": "",
"MarketplaceURL": "",
"CanContainContent": true,
"IsBetaVersion": false,
"IsExperimentalVersion": false,
"Installed": false,
"Modules": [
{
"Name": "SUDS",
"Type": "Runtime",
"LoadingPhase": "Default"
},
{
"Name": "SUDSEditor",
"Type": "Editor",
"LoadingPhase": "Default"
},
{
"Name": "SUDSTest",
"Type": "Editor",
"LoadingPhase": "Default"
}
]
}

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
}
}