Repository: flame-engine/flame

Issue:
Lifecycle for `onCommandExecuted` in Jenny
### Problem to solve

Sometimes a user-defined command sets a globally-scoped variable. Currently, there's no way to access the updated value of a variable just after it has been set by the user-defined command. `onCommand` is the only lifecycle method that is tied to commands, and it is triggered before the user-defined command finishes executing.

### Proposal

- Add a new method for DialogueView: `onCommandExecuted` (or a more appropriate name)
- (less sure about this) in `DialogueRunner.deliverCommand`, modify the function body such that `onCommandExecuted` is called after the future returned by `command.execute` resolves.

### More information

_No response_

### Other

- [x] Are you interested in working on a PR for this?


Discussion: I haven't use Jenny much, would it make sense to just trigger `onCommand` after the command has been executed instead? Or is there a natural use-case where it needs to trigger before?
I personally don't have a usecase for the current behavior of `onCommand`, but changing its behavior to triggering after execution is a breaking change, and I would recommend against it. Adding a new lifecycle method is a safer, non-breaking update.

I would also assume whoever it is that implemented it this way has their reasons for doing so.
Alright fair enough! And you're sure that you're not interested in implementing this? 😄 
I'm not confident in this part of the codebase, but if you think the approach I'm thinking of makes sense, then I'll give it a try. 😆 
> I'm not confident in this part of the codebase, but if you think the approach I'm thinking of makes sense, then I'll give it a try. 😆 

The only one that is confident in this part of the codebase is Pasha, and we haven't heard from him for two years since he left Google. 😅
I think you should give it a try! Your plan sounds sound, and while implementing it I think you'll realize whether it is or not.
Hah, thanks for the reassurance! I'll give it a try then.

Fix patch:
diff --git a/examples/assets/yarn/command_lifecycle.yarn b/examples/assets/yarn/command_lifecycle.yarn
new file mode 100644
index 00000000000..94ed056d71e
--- /dev/null
+++ b/examples/assets/yarn/command_lifecycle.yarn
@@ -0,0 +1,7 @@
+<<character Jenny>>
+<<declare $exampleVariable = "foo">>
+title: command_lifecycle
+---
+Jenny: The variable starts out as foo, then becomes bar.
+<<exampleCommand bar>>
+===
\ No newline at end of file
diff --git a/examples/lib/stories/bridge_libraries/flame_jenny/components/command_lifecycle_dialogue_controller.dart b/examples/lib/stories/bridge_libraries/flame_jenny/components/command_lifecycle_dialogue_controller.dart
new file mode 100644
index 00000000000..78f6ec0f8c4
--- /dev/null
+++ b/examples/lib/stories/bridge_libraries/flame_jenny/components/command_lifecycle_dialogue_controller.dart
@@ -0,0 +1,26 @@
+import 'dart:async';
+
+import 'package:examples/stories/bridge_libraries/flame_jenny/components/dialogue_controller_component.dart';
+import 'package:jenny/jenny.dart';
+
+class CommandLifecycleDialogueController extends DialogueControllerComponent {
+  CommandLifecycleDialogueController({
+    required this.onCommandOverride,
+    required this.onCommandFinishOverride,
+  });
+  final FutureOr<void> Function(UserDefinedCommand command) onCommandOverride;
+  final FutureOr<void> Function(UserDefinedCommand command)
+      onCommandFinishOverride;
+
+  @override
+  FutureOr<void> onCommand(UserDefinedCommand command) async {
+    await onCommandOverride(command);
+    return super.onCommand(command);
+  }
+
+  @override
+  FutureOr<void> onCommandFinish(UserDefinedCommand command) async {
+    await onCommandFinishOverride(command);
+    return super.onCommandFinish(command);
+  }
+}
diff --git a/examples/lib/stories/bridge_libraries/flame_jenny/jenny.dart b/examples/lib/stories/bridge_libraries/flame_jenny/jenny.dart
index 648640eb533..03a1a833962 100644
--- a/examples/lib/stories/bridge_libraries/flame_jenny/jenny.dart
+++ b/examples/lib/stories/bridge_libraries/flame_jenny/jenny.dart
@@ -1,6 +1,7 @@
 import 'package:dashbook/dashbook.dart';
 import 'package:examples/stories/bridge_libraries/flame_jenny/commons/commons.dart';
 import 'package:examples/stories/bridge_libraries/flame_jenny/jenny_advanced_example.dart';
+import 'package:examples/stories/bridge_libraries/flame_jenny/jenny_command_lifecycle_example.dart';
 import 'package:examples/stories/bridge_libraries/flame_jenny/jenny_simple_example.dart';
 import 'package:flame/game.dart';
 
@@ -17,5 +18,11 @@ void addFlameJennyExample(Dashbook dashbook) {
       (_) => GameWidget(game: JennyAdvancedExample()),
       codeLink: baseLink('jenny_advanced_example.dart'),
       info: JennyAdvancedExample.description,
+    )
+    ..add(
+      'Command Lifecycle example',
+      (_) => GameWidget(game: JennyCommandLifecycleExample()),
+      codeLink: baseLink('jenny_command_lifecycle_example.dart'),
+      info: JennyCommandLifecycleExample.description,
     );
 }
diff --git a/examples/lib/stories/bridge_libraries/flame_jenny/jenny_command_lifecycle_example.dart b/examples/lib/stories/bridge_libraries/flame_jenny/jenny_command_lifecycle_example.dart
new file mode 100644
index 00000000000..5dc1222dbb0
--- /dev/null
+++ b/examples/lib/stories/bridge_libraries/flame_jenny/jenny_command_lifecycle_example.dart
@@ -0,0 +1,73 @@
+import 'package:examples/stories/bridge_libraries/flame_jenny/components/command_lifecycle_dialogue_controller.dart';
+import 'package:examples/stories/bridge_libraries/flame_jenny/components/menu_button.dart';
+import 'package:flame/components.dart';
+import 'package:flame/experimental.dart';
+import 'package:flame/game.dart';
+import 'package:flutter/services.dart';
+import 'package:jenny/jenny.dart';
+
+class JennyCommandLifecycleExample extends FlameGame {
+  static const String description = '''
+This is an example of how the lifecycle methods relating to user-defined
+commands work.
+  ''';
+
+  final TextComponent onCommandLabel = TextComponent(text: '');
+  final TextComponent onCommandFinishLabel = TextComponent(text: '');
+
+  static const initialOnCommandLabelText = 'onCommand: ???';
+  static const initialOnCommandFinishLabelText = 'onCommandFinish: ???';
+
+  Future<void> startDialogue() async {
+    // Initialize the labels
+    onCommandLabel.text = initialOnCommandLabelText;
+    onCommandFinishLabel.text = initialOnCommandFinishLabelText;
+    final yarnProject = YarnProject();
+    final dialogueControllerComponent = CommandLifecycleDialogueController(
+      onCommandOverride: (command) async {
+        final exampleVariable =
+            yarnProject.variables.getVariable(r'$exampleVariable');
+        onCommandLabel.text = 'onCommand: $exampleVariable';
+      },
+      onCommandFinishOverride: (command) async {
+        final exampleVariable =
+            yarnProject.variables.getVariable(r'$exampleVariable');
+        onCommandFinishLabel.text = 'onCommandFinish: $exampleVariable';
+      },
+    );
+    add(dialogueControllerComponent);
+    add(
+      ColumnComponent(
+        shrinkWrap: true,
+        children: [
+          onCommandLabel,
+          onCommandFinishLabel,
+        ],
+      ),
+    );
+    yarnProject.commands.addCommand1('exampleCommand', (String someData) async {
+      /// Placeholder for some asynchronous event, like... maybe a dice roll.
+      await Future.delayed(const Duration(milliseconds: 300));
+      yarnProject.variables.setVariable(r'$exampleVariable', someData);
+    });
+    yarnProject.parse(
+      await rootBundle.loadString('assets/yarn/command_lifecycle.yarn'),
+    );
+    final dialogueRunner = DialogueRunner(
+      yarnProject: yarnProject,
+      dialogueViews: [dialogueControllerComponent],
+    );
+    dialogueRunner.startDialogue('command_lifecycle');
+  }
+
+  @override
+  Future<void> onLoad() async {
+    addAll([
+      MenuButton(
+        position: Vector2(size.x / 2, 96),
+        onPressed: startDialogue,
+        text: 'Start conversation',
+      ),
+    ]);
+  }
+}
diff --git a/packages/flame_jenny/jenny/lib/src/dialogue_runner.dart b/packages/flame_jenny/jenny/lib/src/dialogue_runner.dart
index 94802f87163..9f112de7981 100644
--- a/packages/flame_jenny/jenny/lib/src/dialogue_runner.dart
+++ b/packages/flame_jenny/jenny/lib/src/dialogue_runner.dart
@@ -192,10 +192,22 @@ class DialogueRunner {
   @internal
   Future<void> deliverCommand(Command command) async {
     await _combineFutures([
+      // Start execution of commands
       command.execute(this),
+      // Call [onCommand] for all the views registered with this runner
+      // so they can be notified that execution of the command has started.
       if (command is UserDefinedCommand)
         for (final view in _dialogueViews) view.onCommand(command),
     ]);
+    // The thing we actually want to wait for is the result of
+    // [command.execute(this)]. It also makes sense to wait until all
+    // [onCommand] invocations are complete because conceptually,
+    // [onCommand] should always precede [onCommandFinish].
+    if (command is UserDefinedCommand) {
+      await _combineFutures([
+        for (final view in _dialogueViews) view.onCommandFinish(command),
+      ]);
+    }
   }
 
   @internal
diff --git a/packages/flame_jenny/jenny/lib/src/dialogue_view.dart b/packages/flame_jenny/jenny/lib/src/dialogue_view.dart
index 095f3334eb7..508418f16a2 100644
--- a/packages/flame_jenny/jenny/lib/src/dialogue_view.dart
+++ b/packages/flame_jenny/jenny/lib/src/dialogue_view.dart
@@ -182,13 +182,16 @@ abstract mixin class DialogueView {
   /// Called when the dialogue encounters a [[user-defined command]].
   ///
   /// This method is invoked immediately after the command itself is executed,
-  /// but before the result of the execution was awaited. Thus, if the command's
-  /// effect is asynchronous, then it will be send to dialogue views and
-  /// executed *at the same time*.
+  /// but before the result of the execution was awaited.
+  /// (See [onCommandFinish]) Thus, if the command's effect is asynchronous,
+  /// then it will be send to dialogue views and executed *at the same time*.
   ///
   /// In cases when the command's effect occurs within the game, implementing
   /// this method may not be necessary. However, if you want to have a command
   /// that affects the dialogue views themselves, then this method provides
   /// a way of achieving that.
   FutureOr<void> onCommand(UserDefinedCommand command) {}
+
+  /// Called after the result of the [command] has been awaited.
+  FutureOr<void> onCommandFinish(UserDefinedCommand command) {}
 }


Test patch:
diff --git a/packages/flame_jenny/jenny/test/dialogue_view_test.dart b/packages/flame_jenny/jenny/test/dialogue_view_test.dart
index 3a22ce0752d..7d80183023a 100644
--- a/packages/flame_jenny/jenny/test/dialogue_view_test.dart
+++ b/packages/flame_jenny/jenny/test/dialogue_view_test.dart
@@ -46,6 +46,7 @@ void main() {
           'onLineStart(Last line)',
           'onLineFinish(Last line)',
           'onCommand(<<Command(myCommand)>>)',
+          'onCommandFinish(<<Command(myCommand)>>)',
           'onNodeFinish(Start)',
           'onDialogueFinish()',
         ],
@@ -90,6 +91,7 @@ void main() {
           'onLineStart(Last line)',
           'onLineFinish(Last line)',
           'onCommand(<<Command(myCommand)>>)',
+          'onCommandFinish(<<Command(myCommand)>>)',
           'onNodeFinish(Start)',
           'onDialogueFinish()',
         ],
@@ -271,6 +273,11 @@ class _RecordingDialogueView extends DialogueView {
     events.add('onCommand(<<$command>>)');
   }
 
+  @override
+  FutureOr<void> onCommandFinish(UserDefinedCommand command) {
+    events.add('onCommandFinish(<<$command>>)');
+  }
+
   @override
   FutureOr<void> onDialogueFinish() {
     events.add('onDialogueFinish()');
@@ -340,6 +347,11 @@ class _RecordingDialogueViewAsMixin extends _SomeOtherBaseClass
     events.add('onCommand(<<$command>>)');
   }
 
+  @override
+  FutureOr<void> onCommandFinish(UserDefinedCommand command) {
+    events.add('onCommandFinish(<<$command>>)');
+  }
+
   @override
   FutureOr<void> onDialogueFinish() {
     events.add('onDialogueFinish()');