Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Added DialogueView.onNodeFinish event #2229

Merged
merged 17 commits into from
Dec 23, 2022
7 changes: 6 additions & 1 deletion doc/other_modules/jenny/runtime/dialogue_runner.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ The constructor takes two required parameters:

## Methods

**runNode**(`String nodeName`)
**startDialogue**(`String nodeName`)
: Executes the node with the given name, and returns a future that completes only when the dialogue
finishes running (which may be a while). A single `DialogueRunner` can only run one node at a
time.
Expand Down Expand Up @@ -93,6 +93,11 @@ the sequence of emitted events will be as follows (assuming the second option is
- `onNodeFinish(Node("Away"))`
- `onDialogueFinish()`

:::{note}
Keep in mind that if a `DialogueError` is thrown while running the dialogue, then the dialogue will
terminate immediately and none of the `*Finish` callbacks will run.
:::


[DialogueChoice]: dialogue_choice.md
[DialogueView]: dialogue_view.md
Expand Down
11 changes: 7 additions & 4 deletions doc/other_modules/jenny/runtime/dialogue_view.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,15 +39,18 @@ simultaneously).
: Called when the dialogue is about to finish.

**onNodeStart**(`Node node`)
: Called when the dialogue runner starts executing the [Node]. This will be called at the start of
`DialogueView.runNode()` (but after the **onDialogueStart**), and then each time the dialogue
jumps to another node.
: Called when the dialogue runner starts executing the [Node]. This will be called right after the
**onDialogueStart** event, and then each time the dialogue jumps to another node.

This method is a good place to perform node-specific initialization, for example by querying the
`node`'s properties or metadata.

**onNodeFinish**(`Node node`)
: TODO
: Called when the dialogue runner finishes executing the [Node], before **onDialogueFinish**. This
will also be called every time a node is exited via `<<stop>>` or a `<<jump>>` command (including
jumps from node to itself).

This callback can be used to clean up any preparations that were performed in `onNodeStart`.

**onLineStart**(`DialogueLine line`) `-> bool`
: Called when the next dialogue [line] should be presented to the user.
Expand Down
161 changes: 87 additions & 74 deletions packages/flame_jenny/jenny/lib/src/dialogue_runner.dart
Original file line number Diff line number Diff line change
Expand Up @@ -35,69 +35,50 @@ class DialogueRunner {
required YarnProject yarnProject,
required List<DialogueView> dialogueViews,
}) : project = yarnProject,
_dialogueViews = dialogueViews,
_currentNodes = [],
_iterators = [];
_dialogueViews = dialogueViews;

final YarnProject project;
final List<DialogueView> _dialogueViews;
final List<Node> _currentNodes;
final List<NodeIterator> _iterators;
_LineDeliveryPipeline? _linePipeline;
Node? _currentNode;
NodeIterator? _currentIterator;
String? _initialNodeName;
String? _nextNode;

/// Executes the node with the given name, and returns a future that finishes
/// once the dialogue stops running.
Future<void> runNode(String nodeName) async {
/// Starts the dialogue with the node [nodeName], and returns a future that
/// finishes once the dialogue stops running.
Future<void> startDialogue(String nodeName) async {
try {
if (_currentNodes.isNotEmpty) {
if (_initialNodeName != null) {
throw DialogueError(
'Cannot run node "$nodeName" because another node is '
'currently running: "${_currentNodes.last.title}"',
'currently running: "$_initialNodeName"',
);
}
final newNode = project.nodes[nodeName];
if (newNode == null) {
throw NameError('Node "$nodeName" could not be found');
}
_dialogueViews.forEach((dv) => dv.dialogueRunner = this);
_currentNodes.add(newNode);
_iterators.add(newNode.iterator);
await _combineFutures(
[for (final view in _dialogueViews) view.onDialogueStart()],
);
await _combineFutures(
[for (final view in _dialogueViews) view.onNodeStart(newNode)],
);

while (_iterators.isNotEmpty) {
final iterator = _iterators.last;
if (iterator.moveNext()) {
final entry = iterator.current;
await entry.processInDialogueRunner(this);
} else {
_finishCurrentNode();
_initialNodeName = nodeName;
_dialogueViews.forEach((view) {
if (view.dialogueRunner != null) {
throw DialogueError(
'DialogueView is currently attached to another DialogueRunner',
);
}
view.dialogueRunner = this;
});
await _event((view) => view.onDialogueStart());
_nextNode = nodeName;
while (_nextNode != null) {
await _runNode(_nextNode!);
}
await _combineFutures(
[for (final view in _dialogueViews) view.onDialogueFinish()],
);
await _event((view) => view.onDialogueFinish());
} finally {
_dialogueViews.forEach((dv) => dv.dialogueRunner = null);
_initialNodeName = null;
_nextNode = null;
_currentIterator = null;
_currentNode = null;
}
}

void _finishCurrentNode() {
// Increment visit count for the node
assert(_currentNodes.isNotEmpty);
final nodeVariable = '@${_currentNodes.last.title}';
project.variables.setVariable(
nodeVariable,
project.variables.getNumericValue(nodeVariable) + 1,
);
_currentNodes.removeLast();
_iterators.removeLast();
}

void sendSignal(dynamic signal) {
assert(_linePipeline != null);
final line = _linePipeline!.line;
Expand All @@ -110,6 +91,36 @@ class DialogueRunner {
_linePipeline?.stop();
}

Future<void> _runNode(String nodeName) async {
final node = project.nodes[nodeName];
if (node == null) {
throw NameError('Node "$nodeName" could not be found');
}

_nextNode = null;
_currentNode = node;
_currentIterator = node.iterator;

await _event((view) => view.onNodeStart(node));
while (_currentIterator?.moveNext() ?? false) {
final entry = _currentIterator!.current;
await entry.processInDialogueRunner(this);
}
_incrementNodeVisitCount();
await _event((view) => view.onNodeFinish(node));

_currentNode = null;
_currentIterator = null;
}

void _incrementNodeVisitCount() {
final nodeVariable = '@${_currentNode!.title}';
project.variables.setVariable(
nodeVariable,
project.variables.getNumericValue(nodeVariable) + 1,
);
}

@internal
Future<void> deliverLine(DialogueLine line) async {
final pipeline = _LineDeliveryPipeline(line, _dialogueViews);
Expand All @@ -125,19 +136,23 @@ class DialogueRunner {
for (final view in _dialogueViews) view.onChoiceStart(choice)
];
if (futures.every((future) => future == DialogueView.never)) {
_error('No dialogue views capable of making a dialogue choice');
throw DialogueError(
'No dialogue views capable of making a dialogue choice',
);
}
final chosenIndex = await Future.any(futures);
if (chosenIndex < 0 || chosenIndex >= choice.options.length) {
_error('Invalid option index chosen in a dialogue: $chosenIndex');
throw DialogueError(
'Invalid option index chosen in a dialogue: $chosenIndex',
);
}
final chosenOption = choice.options[chosenIndex];
if (!chosenOption.isAvailable) {
_error('A dialogue view selected a disabled option: $chosenOption');
throw DialogueError(
'A dialogue view selected a disabled option: $chosenOption',
);
}
await _combineFutures(
[for (final view in _dialogueViews) view.onChoiceFinish(chosenOption)],
);
await _event((view) => view.onChoiceFinish(chosenOption));
enterBlock(chosenOption.block);
}

Expand All @@ -152,37 +167,35 @@ class DialogueRunner {

@internal
void enterBlock(Block block) {
_iterators.last.diveInto(block);
}

@internal
Future<void> jumpToNode(String nodeName) async {
_finishCurrentNode();
return runNode(nodeName);
_currentIterator!.diveInto(block);
}

/// Stops the current node, and then starts running [nodeName]. If [nodeName]
/// is null, then stops the dialogue completely.
///
/// This command is synchronous, i.e. it does not wait for the node to
/// *actually* finish (which calls the `onNodeFinish` callback).
@internal
void stop() {
_currentNodes.clear();
_iterators.clear();
void jumpToNode(String? nodeName) {
_currentIterator = null;
_nextNode = nodeName;
}

/// Similar to `Future.wait()`, but accepts `FutureOr`s.
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;
final futures = maybeFutures.whereType<Future<void>>().toList();
if (futures.isNotEmpty) {
if (futures.length == 1) {
return futures[0];
} else {
final Future<void> result = Future.wait(futures);
return result;
}
}
}

Never _error(String message) {
stop();
throw DialogueError(message);
FutureOr<void> _event(FutureOr<void> Function(DialogueView) callback) {
return _combineFutures([for (final view in _dialogueViews) callback(view)]);
}
}

Expand Down
8 changes: 8 additions & 0 deletions packages/flame_jenny/jenny/lib/src/dialogue_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ abstract class DialogueView {
/// to complete before proceeding with the actual dialogue.
FutureOr<void> onNodeStart(Node node) {}

/// Called when the dialogue exits the [node].
///
/// For example, during a `<<jump>>` this callback will be called with the
/// current node, and then [onNodeStart] will be called with the new node.
/// Similarly, the command `<<stop>>` will trigger this callback too. At the
/// same time, during `<<visit>>` this callback will not be invoked.
FutureOr<void> onNodeFinish(Node node) {}

/// Called when the next dialogue [line] should be presented to the user.
///
/// The [DialogueView] may decide to present the [line] in whatever way it
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ class JumpCommand extends Command {
String get name => 'jump';

@override
Future<void> execute(DialogueRunner dialogue) {
return dialogue.jumpToNode(target.value);
void execute(DialogueRunner dialogue) {
dialogue.jumpToNode(target.value);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,6 @@ class StopCommand extends Command {

@override
void execute(DialogueRunner dialogue) {
dialogue.stop();
dialogue.jumpToNode(null);
}
}
18 changes: 9 additions & 9 deletions packages/flame_jenny/jenny/test/dialogue_runner_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ void main() {
);
final view = _RecordingDialogueView();
final dialogue = DialogueRunner(yarnProject: yarn, dialogueViews: [view]);
await dialogue.runNode('Hamlet');
await dialogue.startDialogue('Hamlet');
expect(
view.events,
[
Expand Down Expand Up @@ -93,7 +93,7 @@ void main() {
yarnProject: yarn,
dialogueViews: [view1, view2, view3],
);
await dialogue.runNode('The Robot and the Mattress');
await dialogue.startDialogue('The Robot and the Mattress');
expect(events, [
'[A] onDialogueStart()',
'[B] onDialogueStart()',
Expand Down Expand Up @@ -130,7 +130,7 @@ void main() {
'===\n');
final view = _RecordingDialogueView(choices: [1]);
final dialogue = DialogueRunner(yarnProject: yarn, dialogueViews: [view]);
await dialogue.runNode('X');
await dialogue.startDialogue('X');
expect(
view.events,
[
Expand All @@ -149,7 +149,7 @@ void main() {

view.events.clear();
view.choices.add(0);
await dialogue.runNode('X');
await dialogue.startDialogue('X');
expect(
view.events,
[
Expand All @@ -174,11 +174,11 @@ void main() {
final view = _RecordingDialogueView(choices: [2, 1]);
final dialogue = DialogueRunner(yarnProject: yarn, dialogueViews: [view]);
await expectLater(
() => dialogue.runNode('A'),
() => dialogue.startDialogue('A'),
hasDialogueError('Invalid option index chosen in a dialogue: 2'),
);
await expectLater(
() => dialogue.runNode('A'),
() => dialogue.startDialogue('A'),
hasDialogueError(
'A dialogue view selected a disabled option: '
'Option(Only two #disabled)',
Expand All @@ -193,7 +193,7 @@ void main() {
dialogueViews: [_SimpleDialogueView(), _SimpleDialogueView()],
);
expect(
() => dialogue.runNode('A'),
() => dialogue.startDialogue('A'),
hasDialogueError(
'No dialogue views capable of making a dialogue choice',
),
Expand Down Expand Up @@ -327,9 +327,9 @@ void main() {
);
final view = _RecordingDialogueView();
final dialogue = DialogueRunner(yarnProject: yarn, dialogueViews: [view]);
unawaited(dialogue.runNode('Start'));
unawaited(dialogue.startDialogue('Start'));
expect(
() => dialogue.runNode('Other'),
() => dialogue.startDialogue('Other'),
hasDialogueError(
'Cannot run node "Other" because another node is currently running: '
'"Start"',
Expand Down
Loading