Skip to content

Commit

Permalink
feat: Arguments of a UserDefinedCommand are now accessible (#2224)
Browse files Browse the repository at this point in the history
Class UserDefinedCommand now exposes argumentString and arguments properties, allowing them to be queried in a DialogueView;
Make sure the arguments of a user-defined command are computed only once per invocation;
Added documentation for user-defined commands;
addDialogueCommand renamed into addOrphanedCommand.
  • Loading branch information
st-pasha authored Dec 21, 2022
1 parent e302f8e commit 0a9eaf3
Show file tree
Hide file tree
Showing 17 changed files with 423 additions and 42 deletions.
10 changes: 9 additions & 1 deletion doc/_sphinx/extensions/yarn_lexer.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ class YarnLexer(RegexLexer):
'if',
'jump',
'local',
'set',
'stop',
'visit',
'wait',
Expand Down Expand Up @@ -123,7 +124,7 @@ class YarnLexer(RegexLexer):
],
'command_name': [
(words(BUILTIN_COMMANDS, suffix=r'\b'), Keyword, 'command_body'),
(r'\w+', Name.Class, 'command_body'),
(r'\w+', Name.Class, 'command_custom'),
],
'command_body': [
include('<whitespace>'),
Expand All @@ -132,6 +133,13 @@ class YarnLexer(RegexLexer):
(r'>', Text),
default('command_expression'),
],
'command_custom': [
include('<whitespace>'),
(r'>>', Punctuation, '#pop:2'),
(r'\{', Punctuation, 'curly_expression'),
(r'[^>{\n]+', Text),
('.', Text),
],

'<expression>': [
(r'\n', Error),
Expand Down
4 changes: 4 additions & 0 deletions doc/_sphinx/theme/flames.css
Original file line number Diff line number Diff line change
Expand Up @@ -642,6 +642,10 @@ div.highlight-yaml pre:before {
content: "yaml";
color: #5f5;
}
div.highlight-yarn pre:before {
content: "yarn";
color: #a6e22e;
}
div.highlight-text pre:before {
content: "text";
color: #666;
Expand Down
13 changes: 7 additions & 6 deletions doc/other_modules/jenny/language/commands/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@ are both built-in and user-defined commands.
```{toctree}
:hidden:
<<declare>> <declare.md>
<<if>> <if.md>
<<jump>> <jump.md>
<<set>> <set.md>
<<stop>> <stop.md>
<<wait>> <wait.md>
<<declare>> <declare.md>
<<if>> <if.md>
<<jump>> <jump.md>
<<set>> <set.md>
<<stop>> <stop.md>
<<wait>> <wait.md>
User-defined commands <user_defined_commands.md>
```
37 changes: 37 additions & 0 deletions doc/other_modules/jenny/language/commands/user_defined_commands.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# User-defined commands

In addition to the built-in commands, you can also declare your own **user-defined commands** for
use in your yarn scripts. Typically, these commands would perform some in-game action that can be
viewed as a natural part of the dialogue. For example, you can create commands for such action as
`<<wave>>`, `<<smile>>`, `<<frown>>`, `<<moveCamera>>`, `<<zoom>>`, `<<shakeCamera>>`,
`<<fadeOut>>`, `<<walk>>`, `<<give>>`, `<<take>>`, `<<achievement>>`, `<<GainExperience>>`,
`<<startQuest>>`, `<<finishQuest>>`, `<<openTrade>>`, `<<drawWeapon>>`, and so on.

In many cases, the commands will need to take arguments. The arguments of a user-defined command
are processed according to the following rules:

- First, all content after the command name and until the closing `>>` is parsed according to the
rules of regular line parsing, where interpolated expressions are allowed but markup and hashtags
are not.
- At runtime, the content of that line is evaluated, meaning that we substitute the values of all
expressions.
- The evaluated argument string is then broken into individual arguments at whitespace, and the
types of these arguments are checked against the signature of the backing function.
- Then, the backing function is called with the parsed arguments.
- Lastly, all dialogue views in the dialogue runner receive the `onCommand()` event.

As a concrete example, consider the following command:

```yarn
<<give Gold {round(100 * $multiplier)}>>
```

First note that, unlike builtin commands, the arguments of the command are treated as text, and any
expressions need to be placed in curly brackets.

Then, at runtime the expression is evaluated, and (assuming `$multiplier` is 1.5) the command's
argument string becomes `"Gold 150"`. The string is then broken at white spaces and each argument
is parsed according to its type in the backing Dart function. For example, if the function's
signature is `void give(String item, int? amount)`, then it will be invoked as `give("Gold", 150)`.
If, on the other hand, the number or types of arguments do not match the expected signature, then
a `DialogueException` will be raised.
179 changes: 179 additions & 0 deletions doc/other_modules/jenny/runtime/command_storage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
# CommandStorage

The **CommandStorage** is a part of [YarnProject] responsible for storing all [user-defined
commands]. You can access it as the `YarnProject.commands` property.

The command storage can be used to register any number of custom commands, making them available to
use in yarn scripts. Such commands must be registered before parsing the yarn scripts, or the
compiler will throw an error that the command is not recognized.

In order to register a function as a yarn command, the function must satisfy several requirements:

- The function's return value must be `void` or `Future<void>`. If the function returns a future,
then that future will be awaited before proceeding to the next step of the dialogue. This makes it
possible to create commands that take a certain time to unfold in the game, for example
`<<walk>>`, `<<moveCamera>>`, or `<<prompt>>`.
- The function's arguments must be of types that are known to Yarn: `String`, `num`, `int`,
`double`, or `bool`. All arguments must be positional, with no defaults.
- In order to register the function, use methods `addCommand0()` ... `addCommand3()`, according to
the number of function's arguments.
- If the function's signature has 1 or more booleans at the end, then those arguments will be
considered optional and will default to `false`.


## Properties

**trueValues**, **falseValues** `Set<String>`
: The strings that can be recognized as `true`/`false` values respectively.


## Methods

**hasCommand**(`String name`) → `bool`
: Returns the status of whether the command `name` has been added to the storage.

**addCommand0**(`String name`, `FutureOr<void> Function() fn`)
: Registers a no-argument function `fn` as the command `name`.

**addCommand1**(`String name`, `FutureOr<void> Function(T1) fn`)
: Registers a single-argument function `fn` as the command `name`.

**addCommand2**(`String name`, `FutureOr<void> Function(T1, T2) fn`)
: Registers a two-argument function `fn` as the command `name`.

**addCommand3**(`String name`, `FutureOr<void> Function(T1, T2, T3) fn`)
: Registers a three-argument function `fn` as the command `name`.

**addOrphanedCommand**(`name`)
: Registers a command `name` which is not backed by any Dart function. Such command will still be
delivered to [DialogueView]s via the `onCommand()` callback, but its arguments will not be parsed.


## Examples


### `<<StartQuest>>`

Suppose we want to have a yarn command `<<StartQuest>>`, which would initiate a quest. The command
would take the quest name and quest ID as arguments. Technically, just the ID should be enough --
but then it would be really difficult to read the yarn script and understand what quest is being
initiated. So, instead we'll pass both the ID and the name, and then check at runtime that the ID
of the quest matches its name.

A typical invocation of this command might look like this (note that the name of the quest is in
quotes, otherwise it would be parsed as four different arguments `"Get"`, `"rid"`, `"of"`, and
`"bandits"`):

```yarn
<<StartQuest Q037 "Get rid of bandits">>
```

In order to implement this command, we create a Dart function `startQuest()` with two string
arguments. The function will do a brief animated "Started quest X" message, but we don't want the
game to dialogue to wait for that message, so we'll make the function return `void`, not a future.
Finally, we register the command with `commands.addCommand2()`.

```dart
class MyGame {
late YarnProject yarnProject;
void startQuest(String questId, String questName) {
assert(quests.containsKey(questId));
assert(quests[questId]!.name == questName);
// ...
}
Future<void> onLoad() async {
yarnProject = YarnProject()
..commands.addCommand2('StartQuest', startQuest);
}
}
```

Note that the name of the Dart function is different from the name of the command -- you can choose
whatever names suit your programming style best.


### `<<prompt>>`

The `<<prompt>>` function will open a modal dialogue and ask the user to enter their response. This
command will be waiting for the user's input, so it must return a future. Also, we want to return
the result of the prompt into the dialogue -- but, unfortunately, the commands are not expressions,
and are not supposed to return values. So instead we will write the result into a global variable
`$prompt`, and then the dialogue can access that variable in order to read the result of the prompt.

```dart
class MyGame {
final YarnProject yarnProject = YarnProject();
Future<void> prompt(String message) async {
// This will wait until the modal dialog is popped from the router stack
final name = await router.pushAndWait(KeyboardDialog(message));
yarnProject.variables.setVariable(r'$prompt', name);
}
Future<void> onLoad() async {
yarnProject
..variables.setVariable(r'$prompt', '')
..commands.addCommand1('prompt', prompt);
}
}
```

Then in a yarn script this command can be used like this:

```yarn
<<declare $name as String>>
title: Greeting
---
Guide: Hello, my name is Jenny, and you?
<<prompt "Enter your name:">>
<<set $player = $prompt>> // Store the name for later
Guide: Nice to meet you, {$player}
===
```


### `<<give>>`

Suppose that we want to make a command that will give the player a certain item, or a number of
items. This command would take 3 arguments: the person who gives the items, the name of the item,
and the quantity. For example:

```yarn
<<give {$quest_reward} TraderJoe>>
```

Note that the quest reward variable will contain both the reward item and its amount, for example
it could be `"100 gold"`, `"5 potion_of_healing"`, or `'1 "Sword of Darkness"'`. When such
variable is substituted into the command at runtime, the command becomes equivalent to

```yarn
<<give 100 gold TraderJoe>>
<<give 5 potion_of_healing TraderJoe>>
<<give 1 "Sword of Darkness" TraderJoe>>
```

which will then be parsed as a regular 3-argument command corresponding to the following Dart
function:

```dart
/// Takes [amount] of [item]s from [source] and gives them to the player.
void give(int amount, String item, String source) {
// ...
}
```


## See also

- The description of [user-defined commands] in the YarnSpinner language.
- The [UserDefinedCommand] class, which is used to inform a [DialogueView] that a custom command
is being executed.


[DialogueView]: dialogue_view.md
[UserDefinedCommand]: user_defined_command.md
[YarnProject]: yarn_project.md
[user-defined commands]: ../language/commands/user_defined_commands.md
2 changes: 2 additions & 0 deletions doc/other_modules/jenny/runtime/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@
```{toctree}
:hidden:
CommandStorage <command_storage.md>
DialogueChoice <dialogue_choice.md>
DialogueLine <dialogue_line.md>
DialogueOption <dialogue_option.md>
DialogueRunner <dialogue_runner.md>
DialogueView <dialogue_view.md>
MarkupAttribute <markup_attribute.md>
Node <node.md>
UserDefinedCommand <user_defined_command.md>
YarnProject <yarn_project.md>
```
39 changes: 39 additions & 0 deletions doc/other_modules/jenny/runtime/user_defined_command.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# UserDefinedCommand

The **UserDefinedCommand** class represents a single invocation of a custom (non-built-in) command
within a yarn script. Objects of this type will be delivered to a [DialogueView] in its
`.onCommand()` method.


## Properties

**name** `String`
: The name of the command, without the angle brackets. For example, if the command is `<<smile>>`
in the yarn script, then its name will be `"smile"`.

**argumentString** `String`
: Command arguments, as a single string. For example, if the command is `<<move Hippo {$delta}>>`,
and the value of variable `$delta` is `3.17`, then the argument string will be `"Hippo 3.17"`.

The `argumentString` is re-evaluated every time the command is executed, however, it is an error
to access this property before the command was executed by the dialogue runner.

**arguments** `List<dynamic>?`
: Command arguments, as a list of parsed values. This property will be null if the command was
declared without a signature (i.e. as an "orphaned command"). However, if the command was linked
as an external function, then the number and types of arguments in the list will correspond to
the arguments of that function.

In the same example as above, the `arguments` will be `['Hippo', 3.17]`, assuming the linked Dart
function is `move(String target, double distance)`.


## See also

- The description of [User-defined Commands] in the YarnSpinner language.
- The guide on how to register a new custom command in the [CommandStorage] document.


[CommandStorage]: command_storage.md
[DialogueView]: dialogue_view.md
[User-defined Commands]: ../language/commands/user_defined_commands.md
5 changes: 3 additions & 2 deletions doc/other_modules/jenny/runtime/yarn_project.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,8 @@ final yarn = YarnProject()
dialogue script -- otherwise a compile error will occur when encountering an unknown function.

**commands** `CommandStorage`
: The container for all user-defined commands linked into the project. The main reason to access
this container is to register new custom commands.
: The [container][CommandStorage] for all user-defined commands linked into the project. The main
reason to access this container is to register new custom commands.

All custom commands must be added before they can be used in the dialogue script.

Expand All @@ -74,4 +74,5 @@ final yarn = YarnProject()
existing ones.


[CommandStorage]: command_storage.md
[Node]: node.md
1 change: 1 addition & 0 deletions packages/flame_jenny/jenny/analysis_options.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ linter:
rules:
avoid_equals_and_hash_code_on_mutable_classes: false
hash_and_equals: false
unawaited_futures: true
18 changes: 10 additions & 8 deletions packages/flame_jenny/jenny/lib/src/command_storage.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@ import 'dart:async';

import 'package:jenny/src/errors.dart';
import 'package:jenny/src/parse/ascii.dart';
import 'package:jenny/src/structure/commands/user_defined_command.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],
/// [addCommand1], [addCommand2], [addCommand3], and [addOrphanedCommand],
/// 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 {
Expand Down Expand Up @@ -58,19 +59,20 @@ class CommandStorage {

/// 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) {
void addOrphanedCommand(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.
/// Executes the user-defined [command], if it is defined, otherwise does
/// nothing.
@internal
FutureOr<void> runCommand(String name, String argString) {
final cmd = _commands[name];
FutureOr<void> runCommand(UserDefinedCommand command) {
command.argumentString = command.content.evaluate();
final cmd = _commands[command.name];
if (cmd != null) {
final stringArgs = ArgumentsLexer(argString).tokenize();
final stringArgs = ArgumentsLexer(command.argumentString).tokenize();
final typedArgs = cmd.unpackArguments(stringArgs);
command.arguments = typedArgs;
return cmd.run(typedArgs);
}
}
Expand Down
Loading

0 comments on commit 0a9eaf3

Please sign in to comment.