diff --git a/packages/flame/lib/src/components/core/component.dart b/packages/flame/lib/src/components/core/component.dart index d16ddf6a0b2..f7dfc327185 100644 --- a/packages/flame/lib/src/components/core/component.dart +++ b/packages/flame/lib/src/components/core/component.dart @@ -259,7 +259,20 @@ class Component { /// This can be null if the component hasn't been added to the component tree /// yet, or if it is the root of component tree. /// - /// Setting this property to null is equivalent to [removeFromParent]. + /// Setting this property to `null` is equivalent to [removeFromParent]. + /// Setting it to a new parent component is equivalent to calling + /// [addToParent] and will properly remove this component from its current + /// parent, if any. + /// + /// Note that the [parent] setter, like [add] and similar methods, + /// merely enqueues the move from one parent to another. For example: + /// + /// ```dart + /// coin.parent = inventory; + /// // The inventory.children set does not include coin yet. + /// await game.lifecycleEventsProcessed; + /// // The inventory.children set now includes coin. + /// ``` Component? get parent => _parent; Component? _parent; set parent(Component? newParent) { @@ -543,6 +556,13 @@ class Component { /// The cost of this flexibility is that the component won't be added right /// away. Instead, it will be placed into a queue, and then added later, after /// it has finished loading, but no sooner than on the next game tick. + /// You can await [FlameGame.lifecycleEventsProcessed] like so: + /// + /// ```dart + /// world.add(coin); + /// await game.lifecycleEventsProcessed; + /// // The coin is now guaranteed to be added. + /// ``` /// /// When multiple children are scheduled to be added to the same parent, we /// start loading all of them as soon as possible. Nevertheless, the children diff --git a/packages/flame/lib/src/components/core/component_tree_root.dart b/packages/flame/lib/src/components/core/component_tree_root.dart index b445f7aaa34..7c680021659 100644 --- a/packages/flame/lib/src/components/core/component_tree_root.dart +++ b/packages/flame/lib/src/components/core/component_tree_root.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flame/components.dart'; import 'package:flame/src/components/core/recycled_queue.dart'; import 'package:meta/meta.dart'; @@ -20,6 +22,7 @@ class ComponentTreeRoot extends Component { final Set _blocked; final Set _componentsToRebalance; late final Map _index = {}; + Completer? _lifecycleEventsCompleter; @internal void enqueueAdd(Component child, Component parent) { @@ -76,6 +79,33 @@ class ComponentTreeRoot extends Component { bool get hasLifecycleEvents => _queue.isNotEmpty; + /// A future that will complete once all lifecycle events have been + /// processed. + /// + /// If there are no lifecycle events to be processed ([hasLifecycleEvents] + /// is `false`), then this future returns immediately. + /// + /// This is useful when you modify the component tree + /// (by adding, moving or removing a component) and you want to make sure + /// you react to the changed state, not the current one. + /// Remember, methods like [Component.add] don't act immediately and instead + /// enqueue their action. This action also cannot be awaited + /// with something like `await world.add(something)` since that future + /// completes _before_ the lifecycle events are processed. + /// + /// Example usage: + /// + /// ```dart + /// player.inventory.addAll(enemy.inventory.children); + /// await game.lifecycleEventsProcessed; + /// updateUi(player.inventory); + /// ``` + Future get lifecycleEventsProcessed { + return !hasLifecycleEvents + ? Future.value() + : (_lifecycleEventsCompleter ??= Completer()).future; + } + void processLifecycleEvents() { assert(_blocked.isEmpty); var repeatLoop = true; @@ -109,6 +139,11 @@ class ComponentTreeRoot extends Component { } _blocked.clear(); } + + if (!hasLifecycleEvents && _lifecycleEventsCompleter != null) { + _lifecycleEventsCompleter!.complete(); + _lifecycleEventsCompleter = null; + } } void processRebalanceEvents() { diff --git a/packages/flame/test/components/component_test.dart b/packages/flame/test/components/component_test.dart index acb9a942e23..bf24deb089a 100644 --- a/packages/flame/test/components/component_test.dart +++ b/packages/flame/test/components/component_test.dart @@ -327,6 +327,82 @@ void main() { expect(child.isMounted, true); }, ); + + group('lifecycleEventsProcessed', () { + testWithFlameGame('waits for unprocessed events', (game) async { + await game.ready(); + final component = _LifecycleComponent(); + await game.world.add(component); + expect(game.hasLifecycleEvents, isTrue); + + Future.delayed(Duration.zero).then((_) => game.update(0)); + await game.lifecycleEventsProcessed; + expect(game.hasLifecycleEvents, isFalse); + }); + + testWithFlameGame("doesn't block when there are no events", + (game) async { + await game.ready(); + expect(game.hasLifecycleEvents, isFalse); + await game.lifecycleEventsProcessed; + expect(game.hasLifecycleEvents, isFalse); + }); + + testWithFlameGame('guarantees addition even with heavy onLoad', + (game) async { + await game.ready(); + final component = _SlowComponent('heavy', 0.1); + final child = _SlowComponent('child', 0.1); + await component.add(child); + await game.world.add(component); + expect(game.world.children, isNot(contains(component))); + + game.lifecycleEventsProcessed.then( + expectAsync1((_) { + expect(game.world.children, contains(component)); + expect(component.children, contains(child)); + }), + ); + + await game.ready(); + }); + + testWithFlameGame('completes even with dequeued event', (game) async { + final parent1 = Component(); + final parent2 = Component(); + game.addAll([parent1, parent2]); + await game.ready(); + final component = _SlowComponent('heavy', 0.1); + final child = _SlowComponent('child', 0.1); + await component.add(child); + await parent1.add(component); + + expect(game.lifecycleEventsProcessed, completes); + + await Future.delayed(Duration.zero).then((_) => game.update(0)); + assert( + game.hasLifecycleEvents, + 'One update should not have been enough ' + 'to add the heavy component', + ); + + // Trigger dequeue. + component.parent = parent2; + + await game.ready(); + }); + }); + + testWithFlameGame('Can wait for lifecycleEventsProcessed', (game) async { + await game.ready(); + final component = Component(); + await game.world.add(component); + expect(game.hasLifecycleEvents, isTrue); + + Future.delayed(Duration.zero).then((_) => game.update(0)); + await game.lifecycleEventsProcessed; + expect(game.hasLifecycleEvents, isFalse); + }); }); group('onGameResize', () {