Skip to content

Commit

Permalink
feat(jenny): Support user-defined commands (#2168)
Browse files Browse the repository at this point in the history
This PR adds support for user-defined commands, as described in https://github.com/YarnSpinnerTool/YarnSpinner/blob/main/Documentation/Yarn-Spec.md#generic-commands.

A yarn command can be bound to a Dart function with up to 3 arguments, and Yarn runtime will ensure that the arguments are correctly parsed and converted into proper types.

In addition there was some cleanup of the tokenizer code, fixing some corner cases and making sure that the FSM flow is easier to follow.
  • Loading branch information
st-pasha authored Nov 14, 2022
1 parent 29db304 commit ffb36a8
Show file tree
Hide file tree
Showing 17 changed files with 1,015 additions and 214 deletions.
1 change: 1 addition & 0 deletions .github/.cspell/gamedev_dictionary.txt
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ abelian
ambiguate
antialiasing
arial
arity
autofocus
backpressure
backtick
Expand Down
345 changes: 345 additions & 0 deletions packages/flame_jenny/jenny/lib/src/command_storage.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,345 @@
import 'dart:async';

import 'package:jenny/src/errors.dart';
import 'package:jenny/src/parse/ascii.dart';
import 'package:meta/meta.dart';

/// [CommandStorage] is the repository of user-defined commands known to the
/// YarnProject.
///
/// This repository is populated by the user, using commands [addCommand0],
/// [addCommand1], [addCommand2], [addCommand3], and [addDialogueCommand],
/// depending on the arity of the function that needs to be invoked. All user-
/// defined commands need to be declared before parsing any Yarn scripts.
class CommandStorage {
CommandStorage() : _commands = {};

final Map<String, _Cmd?> _commands;

/// Tokens that represent valid true/false values when converting an argument
/// into a boolean. These sets can be modified by the user.
static Set<String> trueValues = {'true', 'yes', 'on', '+', 'T', '1'};
static Set<String> falseValues = {'false', 'no', 'off', '-', 'F', '0'};

/// Returns `true` if command with the given [name] has been registered.
bool hasCommand(String name) => _commands.containsKey(name);

/// Registers a no-arguments function [fn] as a custom yarn command [name].
void addCommand0(String name, FutureOr<void> Function() fn) {
_checkName(name);
_commands[name] = _Cmd(name, const <Type>[], (List args) => fn());
}

/// Registers a single-argument function [fn] as a custom yarn command [name].
void addCommand1<T1>(String name, FutureOr<void> Function(T1) fn) {
_checkName(name);
_commands[name] = _Cmd(name, [T1], (List args) => fn(args[0] as T1));
}

/// Registers a 2-arguments function [fn] as a custom yarn command [name].
void addCommand2<T1, T2>(String name, FutureOr<void> Function(T1, T2) fn) {
_checkName(name);
_commands[name] =
_Cmd(name, [T1, T2], (List args) => fn(args[0] as T1, args[1] as T2));
}

/// Registers a 3-arguments function [fn] as a custom yarn command [name].
void addCommand3<T1, T2, T3>(
String name,
FutureOr<void> Function(T1, T2, T3) fn,
) {
_checkName(name);
_commands[name] = _Cmd(
name,
[T1, T2, T3],
(List args) => fn(args[0] as T1, args[1] as T2, args[2] as T3),
);
}

/// Registers a command [name] which is not backed by any Dart function.
/// Instead, this command will be delivered directly to the dialogue views.
void addDialogueCommand(String name) {
_commands[name] = null;
}

/// Executes the command [name], passing it the arguments as a single string
/// [argString]. The caller should check beforehand that the command with
/// such a name exists.
@internal
FutureOr<void> runCommand(String name, String argString) {
final cmd = _commands[name];
if (cmd != null) {
final stringArgs = ArgumentsLexer(argString).tokenize();
final typedArgs = cmd.unpackArguments(stringArgs);
return cmd.run(typedArgs);
}
}

/// Sanity checks for whether it is valid to add a command [name].
void _checkName(String name) {
assert(!hasCommand(name), 'Command <<$name>> has already been defined');
assert(!_builtinCommands.contains(name), 'Command <<$name>> is built-in');
assert(
_rxId.firstMatch(name) != null,
'Command name "$name" is not an identifier',
);
}

static final _rxId = RegExp(r'^[a-zA-Z_]\w*$');

/// The list of built-in commands in Jenny; the user is not allowed to
/// register a command with the same name. Some of the commands in the list
/// are reserved for future use.
static const List<String> _builtinCommands = [
'declare',
'else',
'elseif',
'endif',
'for',
'if',
'jump',
'local',
'set',
'stop',
'stop',
'wait',
'while',
];
}

/// A wrapper around Dart function, which allows that function to be invoked
/// dynamically from the Yarn runtime.
class _Cmd {
_Cmd(this.name, List<Type> types, this._wrappedFn)
: _signature = _unpackTypes(types),
_arguments = List<dynamic>.filled(types.length, null) {
numTrailingBooleans =
_signature.reversed.takeWhile((type) => type == _Type.boolean).length;
}

final String name;
final List<_Type> _signature;
final FutureOr<void> Function(List<dynamic>) _wrappedFn;
final List<dynamic> _arguments;
late final int numTrailingBooleans;

FutureOr<void> run(List<dynamic> arguments) {
return _wrappedFn(arguments);
}

List<dynamic> unpackArguments(List<String> stringArguments) {
if (stringArguments.length > _arguments.length ||
stringArguments.length + numTrailingBooleans < _arguments.length) {
String plural(int num, String word) => '$num $word${num == 1 ? '' : 's'}';
throw TypeError(
'Command <<$name>> expects ${plural(_arguments.length, 'argument')} '
'but received ${plural(stringArguments.length, 'argument')}',
);
}
for (var i = 0; i < numTrailingBooleans; i++) {
_arguments[_arguments.length - i - 1] = false;
}
for (var i = 0; i < stringArguments.length; i++) {
final strValue = stringArguments[i];
switch (_signature[i]) {
case _Type.boolean:
if (CommandStorage.falseValues.contains(strValue)) {
_arguments[i] = false;
} else if (CommandStorage.trueValues.contains(strValue)) {
_arguments[i] = true;
} else {
throw TypeError(
'Argument ${i + 1} for command <<$name>> has value "$strValue", '
'which is not a boolean',
);
}
break;
case _Type.integer:
final value = int.tryParse(strValue);
if (value == null) {
throw TypeError(
'Argument ${i + 1} for command <<$name>> has value "$strValue", '
'which is not integer',
);
}
_arguments[i] = value;
break;
case _Type.double:
final value = double.tryParse(strValue);
if (value == null) {
throw TypeError(
'Argument ${i + 1} for command <<$name>> has value "$strValue", '
'which is not a floating-point value',
);
}
_arguments[i] = value;
break;
case _Type.numeric:
final value = num.tryParse(strValue);
if (value == null) {
throw TypeError(
'Argument ${i + 1} for command <<$name>> has value "$strValue", '
'which is not numeric',
);
}
_arguments[i] = value;
break;
case _Type.string:
_arguments[i] = strValue;
break;
}
}
return _arguments;
}

static List<_Type> _unpackTypes(List<Type> types) {
final result = List.filled(types.length, _Type.string);
for (var i = 0; i < types.length; i++) {
final expressionType = _typeMap[types[i]];
assert(
expressionType != null,
'Unsupported type ${types[i]} of argument ${i + 1}',
);
result[i] = expressionType!;
}
return result;
}

static const Map<Type, _Type> _typeMap = {
bool: _Type.boolean,
int: _Type.integer,
double: _Type.double,
num: _Type.numeric,
String: _Type.string,
};
}

@visibleForTesting
class ArgumentsLexer {
ArgumentsLexer(this.text);

final String text;
int position = 0;
List<_ModeFn> modeStack = [];
List<String> tokens = [];
StringBuffer buffer = StringBuffer();

List<String> tokenize() {
pushMode(modeStartOfArgument);
while (!eof) {
final ok = (modeStack.last)();
assert(ok);
}
if (modeStack.last == modeTextArgument) {
if (buffer.isNotEmpty) {
finalizeArgument();
}
} else if (modeStack.last == modeQuotedArgument) {
throw DialogueError('Unterminated quoted string');
}
assert(modeStack.last == modeStartOfArgument);
return tokens;
}

bool get eof => position >= text.length;

int get currentCodeUnit =>
position < text.length ? text.codeUnitAt(position) : -1;

bool pushMode(_ModeFn mode) {
modeStack.add(mode);
return true;
}

//----------------------------------------------------------------------------

bool modeStartOfArgument() {
return eatWhitespace() ||
(eatQuote() && pushMode(modeQuotedArgument)) ||
pushMode(modeTextArgument);
}

bool modeTextArgument() {
return (eatWhitespace() && finalizeArgument()) || eatCharacter();
}

bool modeQuotedArgument() {
return (eatQuote() && finalizeArgument() && checkWhitespaceAfterQuote()) ||
eatEscapedCharacter() ||
eatCharacter();
}

/// Returns true if current character is a whitespace, and skips over it.
bool eatWhitespace() {
final ch = currentCodeUnit;
if (ch == $space || ch == $tab) {
position += 1;
return true;
}
return false;
}

/// Returns true if current character is `"`, and skips over it.
bool eatQuote() {
if (currentCodeUnit == $doubleQuote) {
position += 1;
return true;
}
return false;
}

/// Consumes any character and writes it into the buffer.
bool eatCharacter() {
buffer.writeCharCode(currentCodeUnit);
position += 1;
return true;
}

/// Consumes an escape sequence `\\`, `\"`, or `\n` and writes the
/// corresponding unescaped character into the buffer.
bool eatEscapedCharacter() {
if (currentCodeUnit == $backslash) {
position += 1;
final ch = currentCodeUnit;
if (ch == $backslash || ch == $doubleQuote) {
buffer.writeCharCode(ch);
} else if (ch == $lowercaseN) {
buffer.writeCharCode($lineFeed);
} else {
throw DialogueError(
'Unrecognized escape sequence \\${String.fromCharCode(ch)}',
);
}
position += 1;
return true;
}
return false;
}

bool finalizeArgument() {
tokens.add(buffer.toString());
buffer.clear();
modeStack.removeLast();
assert(modeStack.last == modeStartOfArgument);
return true;
}

/// Throws an error if there is no whitespace after a quoted argument.
bool checkWhitespaceAfterQuote() {
if (eof || eatWhitespace()) {
return true;
}
throw DialogueError('Whitespace is required after a quoted argument');
}
}

typedef _ModeFn = bool Function();

/// Similar to `ExpressionType`, but also allows `integer`.
enum _Type {
boolean,
integer,
double,
numeric,
string,
}
19 changes: 15 additions & 4 deletions packages/flame_jenny/jenny/lib/src/dialogue_runner.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import 'package:jenny/src/dialogue_view.dart';
import 'package:jenny/src/errors.dart';
import 'package:jenny/src/structure/block.dart';
import 'package:jenny/src/structure/commands/command.dart';
import 'package:jenny/src/structure/commands/user_defined_command.dart';
import 'package:jenny/src/structure/dialogue_choice.dart';
import 'package:jenny/src/structure/dialogue_line.dart';
import 'package:jenny/src/structure/node.dart';
Expand Down Expand Up @@ -140,7 +141,11 @@ class DialogueRunner {
}

FutureOr<void> _deliverCommand(Command command) {
return command.execute(this);
return _combineFutures([
command.execute(this),
if (command is UserDefinedCommand)
for (final view in _dialogueViews) view.onCommand(command)
]);
}

@internal
Expand All @@ -161,11 +166,17 @@ class DialogueRunner {
_iterators.clear();
}

Future<void> _combineFutures(List<FutureOr<void>> maybeFutures) {
return Future.wait(<Future<void>>[
FutureOr<void> _combineFutures(List<FutureOr<void>> maybeFutures) {
final futures = <Future<void>>[
for (final maybeFuture in maybeFutures)
if (maybeFuture is Future) maybeFuture
]);
];
if (futures.length == 1) {
return futures[0];
} else if (futures.isNotEmpty) {
final Future<void> result = Future.wait<void>(futures);
return result;
}
}

Never _error(String message) {
Expand Down
Loading

0 comments on commit ffb36a8

Please sign in to comment.