#ifndef DIALOGUE_H
#define DIALOGUE_H
#ifndef _TM_JSON_H_INCLUDED_
# error Include tm_json.h before dialogue.h so dialogue can be loaded.
#endif
#include <stdio.h>
#include <cstdlib>
#include <ctime>
#include <cmath>
#include <functional>
#include <map>
#include <string>
#include <vector>
// Moddable fopen.
#ifndef DIALOGUE_FOPEN
#define DIALOGUE_FOPEN fopen
#endif
namespace dialogue {
// Arithmetic operations on key/value pairs.
struct Operation {
std::string key;
char op;
double valueNumber;
std::string valueKey; // Has length > 0 if value is to be derived from variable.
};
// Interaction contexts, like multiple-choice or visual novel screens.
struct Screen {
std::string id;
std::string bg;
std::string fg;
std::vector<Operation> exec;
std::string caption;
std::vector<std::string> lines;
std::vector<std::string> ids;
};
// Functions.
struct CSLFunction {
std::vector<std::string> argNames;
std::vector<Operation> exec;
};
// Dynamic values.
struct CSLDynamic {
int type; // type is -1 for error, 0 for number, 1 for string, 2 for array.
double valueNumber;
std::string valueString;
std::vector<std::string> valueArray;
};
class Talk {
public:
// Set fallbackDir to alternate folder containing dialogue.
std::string fallbackDir;
std::string file;
Screen screen;
std::vector<Screen> screens;
std::map<std::string,CSLFunction> functions;
std::map<std::string,double> numbers;
std::map<std::string,std::string> strings;
std::map<std::string,std::vector<std::string>> arrays;
std::function<double(std::string)> callback;
CSLDynamic go( const std::string &id );
void append( std::string filePath );
bool hasScreen( const std::string &id );
bool hasFunction( const std::string &func );
Screen getScreen( const std::string &id );
void setScreen( const Screen &scr );
bool hasVariable( const std::string &key );
double getVariable( std::string key );
void setVariable( const std::string &key, double valueNumber );
int parseJSON( std::string &text );
int parseCSL( std::string &text );
CSLDynamic callFunction( const std::string &func, const std::vector<std::string> &args );
std::vector<std::string> tokenize( const std::string &str, const std::string &sep = "\t\x20()," );
std::string stringifyNumber( double n );
std::string stringifyArray( const std::vector<std::string> &arr );
Operation getOperation( std::string key, double valueNumber, const std::string &valueKey );
CSLDynamic operate( Operation o );
void transformString( std::string &str );
Talk( const std::string &filePath = "" );
};
// Load a file, go to a screen, or call a function.
CSLDynamic Talk::go( const std::string &id ){
// go returns 0.0 instead of nil.
CSLDynamic zeroDynamic = { 0, 0.0, "", {} };
if( ( id.length() >= 5 && id.substr( id.length() - 5 ) == ".json" )
|| ( id.length() >= 4 && id.substr( id.length() - 4 ) == ".csl" ) ){
// Load the file specified by id.
screens.clear();
std::string filePath = file.substr( 0, file.find_last_of( '/' ) + 1 ) + id;
append( filePath );
if( file == filePath ){
screen = getScreen( "init" );
}
std::srand( std::time( NULL ) );
}else if( id.find( ")" ) == std::string::npos ){
// Go to the screen specified by id.
screen = getScreen( id );
}else{
// Call a function.
auto tokens = tokenize( id );
if( tokens.size() > 0 ){
std::string func = tokens[0];
tokens.erase( tokens.begin() );
auto result = callFunction( func, tokens );
if( result.type >= 0 ) return result;
}
return zeroDynamic;
}
transformString( screen.caption );
for( std::string &s : screen.lines ){
transformString( s );
}
for( std::string &s : screen.ids ){
transformString( s );
}
// Copy exec to a local stack to allow nested callback operations.
std::vector<Operation> op_stack = screen.exec;
for( Operation &o : op_stack ){
auto result = operate( o );
if( result.type >= 0 ) return result;
}
return zeroDynamic;
}
void Talk::append( std::string filePath ){
FILE *stream = DIALOGUE_FOPEN( filePath.c_str(), "rb" );
if( !stream ){
// Attempt to load from the fallback directory.
std::string altPath;
size_t pos = filePath.find_last_of( '/' );
if( pos != filePath.length() - 1 ){
// File does not look like a directory.
if( pos == std::string::npos ){
altPath = fallbackDir + "/" + filePath;
}else{
altPath = fallbackDir + "/" + filePath.substr( pos + 1 );
}
stream = DIALOGUE_FOPEN( altPath.c_str(), "rb" );
}
}
if( !stream ){
screen = {
"ERROR",
"",
"",
{},
"ERROR: File \"" + filePath + "\" could not be loaded.",
{ "Go to init" },
{ "init" }
};
return;
}
std::string text = "";
char buf[4096];
while( size_t len = fread( buf, 1, sizeof( buf ), stream ) ){
text += std::string( buf, len );
}
fclose( stream );
if( filePath.length() >= 5
&& filePath.substr( filePath.length() - 5 ) == ".json" ){
if( parseJSON( text ) == 0 ) file = filePath;
}else{
if( parseCSL( text ) == 0 ) file = filePath;
}
}
bool Talk::hasScreen( const std::string &id ){
for( Screen &s : screens ){
if( s.id == id ){
return true;
}
}
return false;
}
bool Talk::hasFunction( const std::string &func ){
return functions.find( func ) != functions.end();
}
Screen Talk::getScreen( const std::string &id ){
for( Screen &s : screens ){
if( s.id == id ){
return s;
}
}
return {
"ERROR",
"",
"",
{},
"ERROR: Dialogue screen \"" + id + "\" not found.",
{ "Go to init" },
{ "init" }
};
}
void Talk::setScreen( const Screen &scr ){
for( Screen &s : screens ){
if( s.id == scr.id ){
s = scr;
return;
}
}
screens.push_back( scr );
}
// Return whether the specified numeric variable exists.
bool Talk::hasVariable( const std::string &key ){
return numbers.count( key ) > 0;
}
// Return a numeric value.
double Talk::getVariable( std::string key ){
// Apply string replacement to the key.
transformString( key );
if( key == "RAND" ){
// Returns a (pseudo)random number.
return std::rand();
}else if( key == "CLEARSCREEN" ){
// Clears the current dialogue screen.
screen.bg = "";
screen.fg = "";
screen.caption = "";
screen.lines = {};
screen.ids = {};
}else if( key.length() >= 8 && key.substr( 0, 8 ) == "CALLBACK" ){
// Returns the result of the callback function.
std::string arg = key.length() >= 10 ? key.substr( 9 ) : "";
return callback( arg );
}else if( key.length() >= 6 && key.substr( 0, 6 ) == "STRLEN" ){
// Returns the length of the parameter string.
return std::max( key.length() - 7.0, 0.0 );
}else if( key.length() >= 6 && key.substr( 0, 6 ) == "ARRLEN" ){
// Returns the length of the named array if it exists, otherwise 0.
if( key.back() == '@' ) key.resize( key.length() - 1 );
if( key.length() >= 8 ){
auto it = arrays.find( key.substr( 7 ) );
if( it != arrays.end() ) return it->second.size();
}
}else if( key.length() >= 5 && key.substr( 0, 5 ) == "GOSUB" ){
// Executes the specified dialogue, then returns a number.
std::string arg = key.length() >= 7 ? key.substr( 6 ) : "";
// Screens returning non-numeric types default to 0.0.
return go( arg ).valueNumber;
}else if( key.length() >= 7 && key.substr( 0, 7 ) == "IFGOSUB" ){
// :IFGOSUB condition sub
// If condition value is nonzero, executes sub and returns a number.
// Multiple spaces between params will break the parser, so avoid them.
if( key.length() >= 9 ){
std::string arg = key.substr( 8 );
size_t space_at = arg.find_first_of( "\t\x20" );
if( space_at != std::string::npos
&& space_at + 1 < arg.length()
&& getVariable( arg.substr( 0, space_at ) ) ){
return go( arg.substr( space_at + 1 ) ).valueNumber;
}
}
}else if( key.find( ")" ) != std::string::npos ){
// Calls the specified function and returns its numeric result.
auto tokens = tokenize( key );
if( tokens.size() > 0 ){
std::string func = tokens[0];
tokens.erase( tokens.begin() );
auto result = callFunction( func, tokens );
if( result.type == 0 ){
return result.valueNumber;
}else if( result.type == 1 ){
fprintf(
stderr,
"%s returned a string when a number was expected.\n",
func.c_str()
);
}else if( result.type == 2 ){
fprintf(
stderr,
"%s returned an array when a number was expected.\n",
func.c_str()
);
}
}
}else{
// Returns the value of a variable if it exists, otherwise 0.
auto it = numbers.find( key );
if( it != numbers.end() ) return it->second;
}
return 0.0;
}
void Talk::setVariable( const std::string &key, double valueNumber ){
numbers[key] = valueNumber;
}
int Talk::parseJSON( std::string &text ){
// All code dependent on tm_json.h is contained within this function.
auto allocatedDocument = jsonAllocateDocumentEx( text.c_str(), text.size(), JSON_READER_JSON5 );
auto err = allocatedDocument.document.error;
if( err.type != JSON_OK ){
screen = {
"ERROR",
"",
"",
{},
"JSON ERROR: " +
std::string( jsonGetErrorString( err.type ) ) + " at line " +
std::to_string( err.line ) + ":" +
std::to_string( err.column ),
{ "Go to init" },
{ "init" }
};
jsonFreeDocument( &allocatedDocument );
return err.type;
}
auto viewToString = []( JsonStringView str ){
return std::string( str.data, str.size );
};
for( auto s : allocatedDocument.document.root.getObject() ){
std::vector<Operation> exec;
for( auto o : s.value["exec"].getObject() ){
exec.push_back( getOperation(
viewToString( o.name ),
o.value.getDouble(),
o.value.isString() ? viewToString( o.value.getString() ) : ""
) );
}
std::vector<std::string> lines;
for( auto l : s.value["lines"].getArray() ) lines.push_back( viewToString( l.getString() ) );
std::vector<std::string> ids;
for( auto i : s.value["ids"].getArray() ) ids.push_back( viewToString( i.getString() ) );
Screen scr = {
viewToString( s.name ),
viewToString( s.value["bg"].getString() ),
viewToString( s.value["fg"].getString() ),
exec,
viewToString( s.value["caption"].getString() ),
lines,
ids
};
// Overwrite the screen with the same id if applicable.
bool over = false;
for( auto &screen : screens ){
if( screen.id == scr.id ){
screen = scr;
over = true;
break;
}
}
if( !over ) screens.push_back( scr );
}
jsonFreeDocument( &allocatedDocument );
return 0;
}
int Talk::parseCSL( std::string &text ){
// SET CURRENT SCREEN TO "init".
// FOR LINE IN text:
// IF LINE[0] == '[':
// FIND ']' AND SET CURRENT SCREEN TO THE CONTENTS.
// ELSE:
// LEX AND ADD LINE TO exec.
auto GetScreenPointer = [&]( const std::string &id ){
for( Screen &s : screens ){
if( s.id == id ){
// Screen already exists.
return &s;
}
}
// Create a new screen.
screens.push_back( {} );
screens.back().id = id;
return &screens.back();
};
auto GetFunctionPointer = [&]( const std::string &key ){
auto it = functions.find( key );
if( it != functions.end() ){
// Function already exists.
return &it->second;
}
// Create a new function.
auto result = functions.insert( { key, {} } );
if( result.second ){
// Success.
return &result.first->second;
}else{
// Failure.
fprintf(
stderr,
"Failed to insert CSL function: %s\n",
key.c_str()
);
return (CSLFunction*)nullptr;
}
};
Screen *screen_ptr = GetScreenPointer( "init" );
CSLFunction *function_ptr = nullptr;
auto Lex = [&]( std::string line ){
// Strip only leading whitespace.
size_t start_at = line.find_first_not_of( "\t\x20" );
if( start_at == std::string::npos ) return;
line = line.substr( start_at );
if( line.length() && line[0] == '#' ) return;
if( line.length() >= 2 && line[0] == '[' ){
// Screen declaration.
line.erase( 0, 1 );
size_t bracket_at = line.find_last_of( ']' );
if( bracket_at == std::string::npos ) return;
line.erase( bracket_at );
if( line.length() > 0 ){
screen_ptr = GetScreenPointer( line );
function_ptr = nullptr;
}
}else if( line.length() >= 6 && line.substr( 0, 3 ) == "fun"
&& ( line[3] == '\t' || line[3] == '\x20' ) ){
// Function declaration.
auto tokens = tokenize( line.substr( 4 ) );
// The line should end with a single colon.
// However, the tokenizer technically allows things like:
// fun foo(bar):),(
if( tokens.size() < 2 || tokens.back() != ":" ) return;
function_ptr = GetFunctionPointer( tokens[0] );
screen_ptr = nullptr;
// Remove both the first and last token from the vector.
tokens.pop_back();
tokens.erase( tokens.begin() );
// Set the function's arguments.
function_ptr->argNames = tokens;
// Clear the function's code in case of redefinition.
function_ptr->exec.clear();
}else if( line.length() >= 6 && line.substr( 0, 6 ) == "return"
&& line.find_last_not_of( "\t\x20" ) == 5 ){
// Syntax sweetener to make a naked "return" line return 0.0.
Operation o = getOperation(
"return",
0.0,
""
);
// Add the operation to the appropriate vector.
if( screen_ptr ){
screen_ptr->exec.push_back( o );
}else if( function_ptr ){
function_ptr->exec.push_back( o );
}else{
fprintf(
stderr,
"Failed to lex \"return\" because there was no containing block to return from.\n"
);
}
}else{
std::string key_str, val_str;
size_t colon_at = line.find_first_of( ':' );
if( colon_at == std::string::npos ){
// No variable is provided to receive the return value,
// so process the right side as a numeric expression and
// put the result in the empty string ("") variable.
key_str = "";
val_str = line;
}else{
// A variable is provided for the return value.
key_str = line.substr( 0, colon_at );
val_str = line.substr( colon_at + 1 );
}
char* p;
double num = std::strtod( val_str.c_str(), &p );
Operation o = getOperation(
key_str,
num,
""
);
// If defining a string or array, or value is non-numeric,
// treat value as a string.
if( o.op == '$' || o.op == '@' || *p ) o.valueKey = val_str;
// Add the operation to the appropriate vector.
if( screen_ptr ){
screen_ptr->exec.push_back( o );
}else if( function_ptr ){
function_ptr->exec.push_back( o );
}else{
fprintf(
stderr,
"Failed to lex CSL line because there was no containing block:\n%s\n",
line.c_str()
);
}
}
};
// Lex the text line by line.
size_t start = 0, end = 0;
while( ( end = text.find_first_of( "\r\n", start ) ) != std::string::npos ){
Lex( text.substr( start, end - start ) );
start = end + 1;
}
Lex( text.substr( start ) );
return 0;
}
CSLDynamic Talk::callFunction( const std::string &func, const std::vector<std::string> &args ){
// callFunction returns 0.0 instead of nil.
CSLDynamic zeroDynamic = { 0, 0.0, "", {} };
// Find the function.
auto it = functions.find( func );
if( it == functions.end() ){
fprintf( stderr, "CSL function not found: %s\n", func.c_str() );
return zeroDynamic;
}
CSLFunction &f = it->second;
// Verify that argument counts match.
if( args.size() != f.argNames.size() ){
fprintf(
stderr,
"%s expects %zu argument(s), got %zu\n",
func.c_str(),
f.argNames.size(),
args.size()
);
return zeroDynamic;
}
// Fill the arguments.
for( size_t i = 0; i < f.argNames.size(); i++ ){
const std::string &argName = f.argNames[i];
std::string argValue = args[i];
if( argName.back() == '$' ){
// String argument.
// Use variable names (NOT inline values) for string arguments.
if( argValue.back() == '$' ) argValue.pop_back();
auto it = strings.find( argValue );
if( it == strings.end() ){
fprintf(
stderr,
"Variable %s$ was not found in call to CSL function %s\n",
argValue.c_str(),
func.c_str()
);
return zeroDynamic;
}
strings[argName.substr( 0, argName.length() - 1 )] = it->second;
}else if( argName.back() == '@' ){
// Array argument.
// Use variable names (NOT inline values) for array arguments.
if( argValue.back() == '@' ) argValue.pop_back();
auto it = arrays.find( argValue );
if( it == arrays.end() ){
fprintf(
stderr,
"Variable %s@ was not found in call to CSL function %s\n",
argValue.c_str(),
func.c_str()
);
return zeroDynamic;
}
arrays[argName.substr( 0, argName.length() - 1 )] = it->second;
}else{
// Number argument.
// Variable names and inline values work here.
char* p;
double num = std::strtod( argValue.c_str(), &p );
if( *p ) num = getVariable( argValue );
numbers[argName] = num;
}
}
// Copy f.exec to a local stack to allow nested callback operations.
std::vector<Operation> op_stack = f.exec;
for( Operation &o : op_stack ){
CSLDynamic result = operate( o );
if( result.type >= 0 ) return result;
}
return zeroDynamic;
}
// Split str into a vector of its non-empty components that were
// separated by any of the characters in sep.
std::vector<std::string> Talk::tokenize( const std::string &str, const std::string &sep ){
std::vector<std::string> result;
std::string token;
size_t start = 0, end = 0;
while( ( end = str.find_first_of( sep, start ) ) != std::string::npos ){
token = str.substr( start, end - start );
if( token.length() > 0 ) result.push_back( token );
start = end + 1;
}
token = str.substr( start );
if( token.length() > 0 ) result.push_back( token );
return result;
}
// If n can be losslessly round-tripped between a double, a long long,
// and a double, return a string without a decimal. Otherwise, return a
// string with sprintf-style floating-point notation.
std::string Talk::stringifyNumber( double n ){
long long rounded = std::llround( n );
if( (double)rounded == n ){
// Number is either an integer or so weird it's an extreme corner case.
return std::to_string( rounded );
}else{
// Number is not an integer.
return std::to_string( n );
}
}
// Return a ;-separated list of strings with each string's ; characters
// escaped as: \;
std::string Talk::stringifyArray( const std::vector<std::string> &arr ){
std::string result;
for( size_t i = 0; i < arr.size(); i++ ){
// Add separating ; characters.
if( i > 0 ) result += ";";
// Replace ; characters with \; and add the fragments to result.
const std::string &str = arr[i];
size_t start = 0, end = 0;
while( ( end = str.find( ';', start ) ) != std::string::npos ){
result += str.substr( start, end - start ) + "\\;";
start = end + 1;
}
result += str.substr( start );
}
return result;
}
Operation Talk::getOperation( std::string key, double valueNumber, const std::string &valueKey ){
char op = ':';
if( key.length() > 0 && key.find_last_of( "$@=!<>?%*/+-_" ) == key.length() - 1 ){
op = key.back();
key.resize( key.length() - 1 );
}
return {
key,
op,
valueNumber,
valueKey
};
}
CSLDynamic Talk::operate( Operation o ){
CSLDynamic nilDynamic = { -1, 0.0, "", {} };
// String assignment.
if( o.op == '$' ){
transformString( o.valueKey );
strings[o.key] = o.valueKey;
if( o.key == "return" ){
return { 1, 0.0, o.valueKey, {} };
}else if( o.key == "bg" ){
screen.bg = o.valueKey;
}else if( o.key == "fg" ){
screen.fg = o.valueKey;
}else if( o.key == "caption" ){
screen.caption = o.valueKey;
}
return nilDynamic;
}
// Array assignment.
if( o.op == '@' ){
transformString( o.valueKey );
// Make an empty array and get the iterator.
const auto arr = arrays.insert( { o.key, {} } );
auto &it = arr.first;
bool success = arr.second;
if( !success ) it->second = {};
// Parse the array.
size_t start = 0, off = 0, end = 0;
while( ( end = o.valueKey.find( ';', start + off ) ) != std::string::npos ){
// Allow \; escaping. TODO: \\ escaping.
if( end > 0 && o.valueKey[end - 1] == '\\' ){
// Remove the \ character from the string.
o.valueKey.erase( end - 1, 1 );
// `end` now points to the character after the ;.
off = end - start;
continue;
}
it->second.push_back( o.valueKey.substr( start, end - start ) );
start = end + 1;
off = 0;
}
it->second.push_back( o.valueKey.substr( start ) );
if( o.key == "return" ){
return { 2, 0.0, "", it->second };
}else if( o.key == "lines" ){
screen.lines = it->second;
}else if( o.key == "ids" ){
screen.ids = it->second;
}
return nilDynamic;
}
// Numerical operations.
double in = o.valueNumber, out = getVariable( o.key );
if( o.valueKey.length() > 0 ){
in = getVariable( o.valueKey );
}
switch( o.op ){
case ':': out = in;
break;
case '=': out = ( out == in ) ? 1.0 : 0.0;
break;
case '!': out = ( out != in ) ? 1.0 : 0.0;
break;
case '<': out = ( out < in ) ? 1.0 : 0.0;
break;
case '>': out = ( out > in ) ? 1.0 : 0.0;
break;
case '?': out = out ? in : 0.0;
break;
case '%': out = std::fmod( out, in );
break;
case '*': out *= in;
break;
case '/': out /= in;
break;
case '+': out += in;
break;
case '-': out -= in;
break;
case '_': out = std::floor( in );
}
setVariable( o.key, out );
if( o.key == "return" ) return { 0, out, "", {} };
return nilDynamic;
}
void Talk::transformString( std::string &str ){
bool replacing = false;
std::string outStr = "";
size_t start = 0, i = 0;
for( i = 0; i < str.length(); i++ ){
switch( str[i] ){
case '`':
if( replacing ){
if( i - start > 1 ){
// Add the variable value to the string.
std::string key = str.substr( start + 1, i - start - 1 );
if( key.back() == ')' ){
// Call a function.
auto tokens = tokenize( key );
if( tokens.size() > 0 ){
std::string func = tokens[0];
tokens.erase( tokens.begin() );
auto result =
callFunction( func, tokens );
if( result.type == 0 ){
outStr +=
stringifyNumber( result.valueNumber );
}else if( result.type == 1 ){
outStr += result.valueString;
}else if( result.type == 2 ){
outStr +=
stringifyArray( result.valueArray );
}
}
}else if( key.back() == '$' ){
// String value.
key.resize( key.length() - 1 );
if( key == "bg" ){
outStr += screen.bg;
}else if( key == "fg" ){
outStr += screen.fg;
}else if( key == "caption" ){
outStr += screen.caption;
}else{
auto it = strings.find( key );
if( it != strings.end() )
outStr += it->second;
}
}else if( key.back() == '@' ){
// Array value.
key.resize( key.length() - 1 );
if( key == "lines" ){
outStr += stringifyArray( screen.lines );
}else if( key == "ids" ){
outStr += stringifyArray( screen.ids );
}else{
auto it = arrays.find( key );
if( it != arrays.end() )
outStr += stringifyArray( it->second );
}
}else{
// Number value.
outStr += stringifyNumber( getVariable( key ) );
}
}
start = i + 1;
}else{
if( i >= start ){
outStr += str.substr( start, i - start );
}
start = i;
}
replacing = !replacing;
}
}
if( start < str.length() ){
outStr += str.substr( start );
}
str = outStr;
}
Talk::Talk( const std::string &filePath ){
callback = []( std::string param ){
// Suppress unused parameter warnings.
param = "";
return 0.0;
};
if( filePath.length() > 0 ){
go( filePath );
}
}
} // namespace dialogue
#endif // DIALOGUE_H