From 6624111fc327b2f5374731d64727d1df4e5d735b Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Fri, 14 Aug 2026 21:12:21 +0200 Subject: [PATCH 1/2] fix!: Wait for the whole component tree to load before starting the game --- doc/flame/game.md | 9 +- packages/flame/benchmark/common.dart | 3 + .../lib/src/components/core/component.dart | 30 +++- .../components/core/component_tree_root.dart | 64 ++++++-- packages/flame/lib/src/game/flame_game.dart | 61 +++++-- packages/flame/lib/src/game/game.dart | 8 + .../lib/src/game/game_widget/game_widget.dart | 19 +++ packages/flame/test/game/flame_game_test.dart | 3 + .../game/game_widget/game_widget_test.dart | 155 ++++++++++++++++++ .../flame_test/lib/src/test_flame_game.dart | 3 + 10 files changed, 324 insertions(+), 31 deletions(-) diff --git a/doc/flame/game.md b/doc/flame/game.md index bc895426c46..431cbd6e496 100644 --- a/doc/flame/game.md +++ b/doc/flame/game.md @@ -127,9 +127,12 @@ The `FlameGame` lifecycle callbacks, `onLoad`, `render`, etc. are called in the ``` When a `FlameGame` is first added to a `GameWidget` the lifecycle methods `onGameResize`, `onLoad` -and `onMount` will be called in that order. Then `update` and `render` are called in sequence for -every game tick. If the `FlameGame` is removed from the `GameWidget` then `onRemove` is called. -If the `FlameGame` is added to a new `GameWidget` the sequence repeats from `onGameResize`. +and `onMount` will be called in that order. After that, the `GameWidget` waits for the whole initial +component tree to be loaded and mounted, so the game does not start (and the `loadingBuilder` +widget, if one is set, stays visible) until every component added during `onLoad` is ready. Then +`update` and `render` are called in sequence for every game tick. If the `FlameGame` is removed +from the `GameWidget` then `onRemove` is called. If the `FlameGame` is added to a new `GameWidget` +the sequence repeats from `onGameResize`. ```{note} The order of `onGameResize` and `onLoad` are reversed from that of other diff --git a/packages/flame/benchmark/common.dart b/packages/flame/benchmark/common.dart index 9ba11ce0387..bb0adafd520 100644 --- a/packages/flame/benchmark/common.dart +++ b/packages/flame/benchmark/common.dart @@ -13,5 +13,8 @@ Future mountGame(FlameGame game, {Vector2? size}) async { await game.load(); // ignore: invalid_use_of_internal_member game.mount(); + // Wait for the whole initial component tree to be loaded and mounted, the + // same way the GameWidget does before starting the game. + await game.ready(); game.update(0); } diff --git a/packages/flame/lib/src/components/core/component.dart b/packages/flame/lib/src/components/core/component.dart index 7fca4bb0510..7b01e9f5936 100644 --- a/packages/flame/lib/src/components/core/component.dart +++ b/packages/flame/lib/src/components/core/component.dart @@ -220,6 +220,7 @@ class Component { void _clearRemovedBit() => _state &= ~_removed; Completer? _loadCompleter; + Completer? _loadSettledCompleter; Completer? _mountCompleter; Completer? _removeCompleter; @@ -244,6 +245,22 @@ class Component { : (_loadCompleter ??= Completer()).future; } + /// A future that completes once the [onLoad] step has settled, regardless + /// of whether it succeeded or failed. + /// + /// Unlike [loaded], this future never completes with an error; a load + /// failure is still reported through [loaded], or through the current + /// [Zone] if nothing is awaiting [loaded]. This is used by + /// [FlameGame.ready] to wait for loading components without interfering + /// with how their load errors are reported. + @internal + Future get loadSettled { + if (isLoaded || _loadError != null) { + return Future.value(); + } + return (_loadSettledCompleter ??= Completer()).future; + } + /// A future that will complete once the component is mounted on its parent. /// /// If the component is already mounted (see [isMounted]), this returns an @@ -1061,6 +1078,12 @@ class Component { _setLoadedBit(); _loadCompleter?.complete(); _loadCompleter = null; + _completeLoadSettled(); + } + + void _completeLoadSettled() { + _loadSettledCompleter?.complete(); + _loadSettledCompleter = null; } /// Surfaces an error thrown by [onLoad]. @@ -1085,6 +1108,7 @@ class Component { } else { Zone.current.handleUncaughtError(error, stackTrace); } + _completeLoadSettled(); } /// Mount the component that is already loaded and has a mounted parent. @@ -1159,11 +1183,7 @@ class Component { /// Used by the [FlameGame] to set the loaded state of the component, since /// the game isn't going through the whole normal component life cycle. @internal - void setLoaded() { - _setLoadedBit(); - _loadCompleter?.complete(); - _loadCompleter = null; - } + void setLoaded() => _finishLoading(); /// Used by the [FlameGame] to set the mounted state of the component, since /// the game isn't going through the whole normal component life cycle. 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 b3794c42b01..9fcc1b2b9b7 100644 --- a/packages/flame/lib/src/components/core/component_tree_root.dart +++ b/packages/flame/lib/src/components/core/component_tree_root.dart @@ -26,6 +26,24 @@ class ComponentTreeRoot extends Component { final Set _blocked; late final Map _index = {}; Completer? _lifecycleEventsCompleter; + Completer? _lifecycleEventMutationCompleter; + + /// A future that completes the next time the lifecycle event queue is + /// mutated: when a new event is enqueued or an existing event is cancelled. + /// + /// This is used by `FlameGame.ready` to re-evaluate the queue when it is + /// changed by something other than a component finishing its load, for + /// example when a component is removed while it is still loading. + @internal + Future get nextLifecycleEventMutation => + (_lifecycleEventMutationCompleter ??= Completer()).future; + + void _notifyLifecycleEventMutation() { + if (_lifecycleEventMutationCompleter != null) { + _lifecycleEventMutationCompleter!.complete(); + _lifecycleEventMutationCompleter = null; + } + } @internal void enqueueAdd(Component child, Component parent) { @@ -33,21 +51,33 @@ class ComponentTreeRoot extends Component { ..kind = LifecycleEventKind.add ..child = child ..parent = parent; + _notifyLifecycleEventMutation(); } @internal void dequeueAdd(Component child, Component parent) { - for (final event in queue) { - if (event.kind == LifecycleEventKind.add && + // This uses [RecycledQueue.forEachWhere] instead of iterating over the + // queue, since it can be called from user code that runs while + // [processLifecycleEvents] is iterating over the queue, and the queue + // only supports one iteration at a time. + var found = false; + queue.forEachWhere( + (event) => + !found && + event.kind == LifecycleEventKind.add && event.child == child && - event.parent == parent) { + event.parent == parent, + (event) { + found = true; event.kind = LifecycleEventKind.unknown; - return; - } - } - throw AssertionError( - 'Cannot find a lifecycle event Add(child=$child, parent=$parent)', + }, ); + if (!found) { + throw AssertionError( + 'Cannot find a lifecycle event Add(child=$child, parent=$parent)', + ); + } + _notifyLifecycleEventMutation(); } @internal @@ -56,15 +86,20 @@ class ComponentTreeRoot extends Component { ..kind = LifecycleEventKind.remove ..child = child ..parent = parent; + _notifyLifecycleEventMutation(); } @internal void dequeueRemove(Component child) { - for (final event in queue) { - if (event.kind == LifecycleEventKind.remove && event.child == child) { + // See [dequeueAdd] for why this doesn't iterate over the queue directly. + queue.forEachWhere( + (event) => + event.kind == LifecycleEventKind.remove && event.child == child, + (event) { event.kind = LifecycleEventKind.unknown; - } - } + }, + ); + _notifyLifecycleEventMutation(); } /// Finds all children in [candidates] that have a pending REMOVE event, @@ -86,6 +121,9 @@ class ComponentTreeRoot extends Component { event.kind = LifecycleEventKind.unknown; }, ); + if (result.isNotEmpty) { + _notifyLifecycleEventMutation(); + } } @internal @@ -94,6 +132,7 @@ class ComponentTreeRoot extends Component { ..kind = LifecycleEventKind.move ..child = child ..parent = newParent; + _notifyLifecycleEventMutation(); } @internal @@ -105,6 +144,7 @@ class ComponentTreeRoot extends Component { ..kind = LifecycleEventKind.rebalance ..child = child ..parent = parent; + _notifyLifecycleEventMutation(); } bool get hasLifecycleEvents => queue.isNotEmpty; diff --git a/packages/flame/lib/src/game/flame_game.dart b/packages/flame/lib/src/game/flame_game.dart index 729e2c71ebf..0115cefee4d 100644 --- a/packages/flame/lib/src/game/flame_game.dart +++ b/packages/flame/lib/src/game/flame_game.dart @@ -216,21 +216,60 @@ class FlameGame extends ComponentTreeRoot /// Ensure that all pending tree operations finish. /// - /// This is mainly intended for testing purposes: awaiting on this future - /// ensures that the game is fully loaded, and that all pending operations - /// of adding the components into the tree are fully materialized. + /// Awaiting on this future ensures that all pending operations of adding + /// components into the tree are fully materialized, waiting for any + /// components that are still loading. /// - /// Warning: awaiting on a game that was not fully connected will result in an - /// infinite loop. For example, this could occur if you run `x.add(y)` but + /// The `GameWidget` awaits this future when the game is first shown, so + /// that the game only starts, and the loading widget is only removed, once + /// the whole initial component tree has been loaded and mounted. + /// + /// A component that fails to load does not block this future; its error is + /// reported through its [Component.loaded] future, or the current [Zone] if + /// nothing is awaiting that future. + /// + /// Warning: awaiting on a game that was not fully connected will result in + /// an infinite loop. For example, this could occur if you run `x.add(y)` but /// then forget to mount `x` into the game. + @override Future ready() async { - var repeat = true; - while (repeat) { - // Give chance to other futures to execute first - await Future.delayed(Duration.zero); - repeat = false; + var wake = Completer(); + void wakeUp() { + if (!wake.isCompleted) { + wake.complete(); + } + } + + final watchedChildren = {}; + while (hasLifecycleEvents) { processLifecycleEvents(); - repeat |= hasLifecycleEvents; + if (!hasLifecycleEvents) { + break; + } + if (wake.isCompleted) { + wake = Completer(); + } + var hasLoadingChildren = false; + for (final event in queue) { + final child = event.child; + if (child == null || !child.isLoading) { + continue; + } + hasLoadingChildren = true; + if (watchedChildren.add(child)) { + child.loadSettled.then((_) => wakeUp()); + } + } + if (hasLoadingChildren) { + // Sleep until a load settles, or until the event queue is changed + // from the outside, for example by a component being removed while + // it is still loading. + await Future.any([wake.future, nextLifecycleEventMutation]); + } else { + // The queue is blocked on something other than loading, give other + // futures a chance to execute and try again. + await Future.delayed(Duration.zero); + } } } diff --git a/packages/flame/lib/src/game/game.dart b/packages/flame/lib/src/game/game.dart index 38044e86bf9..7ba3ced38d9 100644 --- a/packages/flame/lib/src/game/game.dart +++ b/packages/flame/lib/src/game/game.dart @@ -136,6 +136,14 @@ abstract mixin class Game { onMount(); } + /// A future that completes when the game is fully ready to start. + /// + /// The `GameWidget` awaits this future after the game has been loaded and + /// mounted, before the game is shown and the first update tick runs. By + /// default it completes immediately; [FlameGame] overrides this to wait + /// until the whole initial component tree has been loaded and mounted. + Future ready() async {} + @mustCallSuper @internal void finalizeRemoval() { diff --git a/packages/flame/lib/src/game/game_widget/game_widget.dart b/packages/flame/lib/src/game/game_widget/game_widget.dart index cf8c474df2f..b25283f857b 100644 --- a/packages/flame/lib/src/game/game_widget/game_widget.dart +++ b/packages/flame/lib/src/game/game_widget/game_widget.dart @@ -112,6 +112,10 @@ class GameWidget extends StatefulWidget { /// Builder to provide a widget which will be displayed while the game is /// loading. By default this is an empty `Container`. + /// + /// For a [FlameGame], the game counts as loading until the whole initial + /// component tree has been loaded and mounted, so the game does not start + /// until every component added during [Game.onLoad] is ready. final GameLoadingWidgetBuilder? loadingBuilder; /// If set, errors during the game loading will be caught and this widget @@ -208,12 +212,27 @@ class GameWidgetState extends State> { final game = currentGame; assert(game.hasLayout); await game.load(); + if (_isStale(game)) { + return; + } game.mount(); + // Wait for the whole component tree to be loaded and mounted, so that + // the game does not start, and the loading widget is not removed, + // until every component added during the initial load is ready. + await game.ready(); + if (_isStale(game)) { + return; + } if (!game.isPaused) { game.update(0); } })(); + /// Whether [game] is no longer the game this widget state is showing, + /// either because the widget was disposed or because the game instance was + /// swapped out while the game was still loading. + bool _isStale(T game) => !mounted || !identical(game, currentGame); + Future? _loaderFuture; late FocusNode _focusNode; diff --git a/packages/flame/test/game/flame_game_test.dart b/packages/flame/test/game/flame_game_test.dart index 055ccddf3bf..4963cfc5319 100644 --- a/packages/flame/test/game/flame_game_test.dart +++ b/packages/flame/test/game/flame_game_test.dart @@ -306,6 +306,9 @@ void main() { await tester.pumpWidget(GameWidget(game: game)); await game.toBeLoaded(); + // The loader also waits for the whole component tree to be ready, so + // an extra pump is needed before the game attaches. + await tester.pump(); await tester.pump(); expect(hasAttached, isTrue); diff --git a/packages/flame/test/game/game_widget/game_widget_test.dart b/packages/flame/test/game/game_widget/game_widget_test.dart index e6a5fb332d9..24b1dfb0621 100644 --- a/packages/flame/test/game/game_widget/game_widget_test.dart +++ b/packages/flame/test/game/game_widget/game_widget_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flame/components.dart'; import 'package:flame/events.dart'; import 'package:flame/game.dart'; @@ -431,6 +433,122 @@ void main() { }); }); + group('loading', () { + testWidgets( + 'loading builder is shown until the whole component tree is loaded', + (tester) async { + final game = _SlowLoadingGame(); + await tester.pumpWidget( + GameWidget( + game: game, + loadingBuilder: (_) => const Directionality( + textDirection: TextDirection.ltr, + child: Text('Loading'), + ), + ), + ); + await game.toBeLoaded(); + await tester.pump(); + + // The game's own onLoad has finished, but the child component is + // still loading, so the game should not have started yet. + expect(find.text('Loading'), findsOneWidget); + expect(game.isAttached, isFalse); + + game.childLoadCompleter.complete(); + await tester.pump(); + + // The grandchild only starts loading once the child has mounted, and + // it should also be waited for. + expect(find.text('Loading'), findsOneWidget); + expect(game.isAttached, isFalse); + + game.grandChildLoadCompleter.complete(); + await tester.pump(); + await tester.pump(); + + expect(find.text('Loading'), findsNothing); + expect(game.isAttached, isTrue); + final slowComponents = game.descendants().whereType<_SlowComponent>(); + expect(slowComponents.length, 2); + expect(slowComponents.every((component) => component.isMounted), true); + }, + ); + + testWidgets( + 'removing a component that is stuck loading lets the game start', + (tester) async { + final game = _SlowLoadingGame(); + await tester.pumpWidget( + GameWidget( + game: game, + loadingBuilder: (_) => const Directionality( + textDirection: TextDirection.ltr, + child: Text('Loading'), + ), + ), + ); + await game.toBeLoaded(); + await tester.pump(); + + expect(find.text('Loading'), findsOneWidget); + + // The slow component never finishes loading, but removing it should + // unblock the game start. + game.slowChild.removeFromParent(); + await tester.pump(); + await tester.pump(); + + expect(find.text('Loading'), findsNothing); + expect(game.isAttached, isTrue); + expect(game.slowChild.isMounted, isFalse); + }, + ); + + testWidgets( + 'game swapped out while loading is never mounted', + (tester) async { + const key = Key('flame-game'); + final loadGate = Completer(); + final slowGame = _GatedLoadGame(loadGate); + await tester.pumpWidget(GameWidget(key: key, game: slowGame)); + + final quickGame = FlameGame(); + await tester.pumpWidget(GameWidget(key: key, game: quickGame)); + expect(quickGame.isMounted, isTrue); + + loadGate.complete(); + await tester.pump(); + await tester.pump(); + + expect(slowGame.isMounted, isFalse); + expect(slowGame.onMountCalled, isFalse); + expect(quickGame.isAttached, isTrue); + }, + ); + + testWidgets( + 'game with a synchronously loaded tree starts fully mounted', + (tester) async { + final game = FlameGame( + children: [ + Component(children: [Component()]), + ], + ); + await tester.pumpWidget(GameWidget(game: game)); + await game.toBeLoaded(); + await tester.pump(); + + expect(game.isAttached, isTrue); + expect(game.hasLifecycleEvents, isFalse); + expect( + game.descendants().every((component) => component.isMounted), + isTrue, + ); + }, + ); + }); + group('buildContext availability', () { testWidgets( 'buildContext is available during onLoad', @@ -467,6 +585,43 @@ void main() { }); } +class _SlowComponent extends Component { + _SlowComponent(this.loadCompleter); + + final Completer loadCompleter; + + @override + Future onLoad() => loadCompleter.future; +} + +class _SlowLoadingGame extends FlameGame { + final childLoadCompleter = Completer(); + final grandChildLoadCompleter = Completer(); + late final _SlowComponent slowChild; + + @override + Future onLoad() async { + slowChild = _SlowComponent(childLoadCompleter) + ..add(_SlowComponent(grandChildLoadCompleter)); + world.add(slowChild); + } +} + +class _GatedLoadGame extends FlameGame { + _GatedLoadGame(this.loadGate); + + final Completer loadGate; + bool onMountCalled = false; + + @override + Future onLoad() => loadGate.future; + + @override + void onMount() { + onMountCalled = true; + } +} + class _GameWithBuildContextCheck extends FlameGame { BuildContext? buildContextDuringOnLoad; diff --git a/packages/flame_test/lib/src/test_flame_game.dart b/packages/flame_test/lib/src/test_flame_game.dart index 9fc0afc08e2..374c8a71fc6 100644 --- a/packages/flame_test/lib/src/test_flame_game.dart +++ b/packages/flame_test/lib/src/test_flame_game.dart @@ -99,6 +99,9 @@ Future initializeGame(CreateFunction create) async { await game.load(); // ignore: invalid_use_of_internal_member game.mount(); + // Wait for the whole initial component tree to be loaded and mounted, the + // same way the GameWidget does before starting the game. + await game.ready(); game.update(0); return game; } From d07ecd8e8cbfb600c009b1ea7c91a3a8d2848ac7 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Fri, 14 Aug 2026 23:11:58 +0200 Subject: [PATCH 2/2] refactor: Address review findings for tree load waiting --- packages/flame/benchmark/common.dart | 2 - .../components/core/component_tree_root.dart | 50 +++++++++++++------ .../src/components/core/recycled_queue.dart | 22 ++++++++ packages/flame/lib/src/game/flame_game.dart | 7 +++ packages/flame/lib/src/game/game.dart | 3 ++ .../lib/src/game/game_widget/game_widget.dart | 18 +++++-- packages/flame/test/game/flame_game_test.dart | 22 ++++++++ .../game/game_widget/game_widget_test.dart | 28 +++++++++-- .../flame/test/math/recycled_queue_test.dart | 46 +++++++++++++++++ .../flame_test/lib/src/test_flame_game.dart | 3 +- 10 files changed, 174 insertions(+), 27 deletions(-) diff --git a/packages/flame/benchmark/common.dart b/packages/flame/benchmark/common.dart index bb0adafd520..7b81e825a35 100644 --- a/packages/flame/benchmark/common.dart +++ b/packages/flame/benchmark/common.dart @@ -13,8 +13,6 @@ Future mountGame(FlameGame game, {Vector2? size}) async { await game.load(); // ignore: invalid_use_of_internal_member game.mount(); - // Wait for the whole initial component tree to be loaded and mounted, the - // same way the GameWidget does before starting the game. await game.ready(); game.update(0); } 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 9fcc1b2b9b7..510a404b746 100644 --- a/packages/flame/lib/src/components/core/component_tree_root.dart +++ b/packages/flame/lib/src/components/core/component_tree_root.dart @@ -39,10 +39,8 @@ class ComponentTreeRoot extends Component { (_lifecycleEventMutationCompleter ??= Completer()).future; void _notifyLifecycleEventMutation() { - if (_lifecycleEventMutationCompleter != null) { - _lifecycleEventMutationCompleter!.complete(); - _lifecycleEventMutationCompleter = null; - } + _lifecycleEventMutationCompleter?.complete(); + _lifecycleEventMutationCompleter = null; } @internal @@ -56,27 +54,22 @@ class ComponentTreeRoot extends Component { @internal void dequeueAdd(Component child, Component parent) { - // This uses [RecycledQueue.forEachWhere] instead of iterating over the - // queue, since it can be called from user code that runs while + // This uses [RecycledQueue.firstWhereOrNull] instead of iterating over + // the queue, since it can be called from user code that runs while // [processLifecycleEvents] is iterating over the queue, and the queue // only supports one iteration at a time. - var found = false; - queue.forEachWhere( + final event = queue.firstWhereOrNull( (event) => - !found && event.kind == LifecycleEventKind.add && event.child == child && event.parent == parent, - (event) { - found = true; - event.kind = LifecycleEventKind.unknown; - }, ); - if (!found) { + if (event == null) { throw AssertionError( 'Cannot find a lifecycle event Add(child=$child, parent=$parent)', ); } + event.kind = LifecycleEventKind.unknown; _notifyLifecycleEventMutation(); } @@ -92,14 +85,18 @@ class ComponentTreeRoot extends Component { @internal void dequeueRemove(Component child) { // See [dequeueAdd] for why this doesn't iterate over the queue directly. + var dequeuedAny = false; queue.forEachWhere( (event) => event.kind == LifecycleEventKind.remove && event.child == child, (event) { event.kind = LifecycleEventKind.unknown; + dequeuedAny = true; }, ); - _notifyLifecycleEventMutation(); + if (dequeuedAny) { + _notifyLifecycleEventMutation(); + } } /// Finds all children in [candidates] that have a pending REMOVE event, @@ -182,7 +179,30 @@ class ComponentTreeRoot extends Component { : (_lifecycleEventsCompleter ??= Completer()).future; } + /// Whether [processLifecycleEvents] is currently running. + /// + /// Used by `FlameGame.ready` to defer its own queue processing when it is + /// called from inside a lifecycle callback, since the queue only supports + /// one iteration at a time. + @internal + bool get isProcessingLifecycleEvents => _processingLifecycleEvents; + bool _processingLifecycleEvents = false; + void processLifecycleEvents() { + assert( + !_processingLifecycleEvents, + 'processLifecycleEvents cannot be called while it is already running, ' + 'for example from inside a lifecycle callback', + ); + _processingLifecycleEvents = true; + try { + _processLifecycleEvents(); + } finally { + _processingLifecycleEvents = false; + } + } + + void _processLifecycleEvents() { // reorder events to process later grouped by parent final reorderParents = {}; LifecycleEventStatus handleReorderEvent(Component parent) { diff --git a/packages/flame/lib/src/components/core/recycled_queue.dart b/packages/flame/lib/src/components/core/recycled_queue.dart index 72fa9c94dc3..6fdb74a4ed9 100644 --- a/packages/flame/lib/src/components/core/recycled_queue.dart +++ b/packages/flame/lib/src/components/core/recycled_queue.dart @@ -209,6 +209,28 @@ class RecycledQueue extends Iterable } } + /// Returns the first element matching [test], or null if there is none, + /// by directly traversing the internal storage. Unlike iteration, this can + /// be safely called while another iteration is in progress. + T? firstWhereOrNull(bool Function(T) test) { + if (isEmpty) { + return null; + } + var i = _startIndex; + while (true) { + if (!_indicesToRemove.contains(i) && test(_elements[i])) { + return _elements[i]; + } + if (i == _endIndex) { + return null; + } + i += 1; + if (i == _elements.length) { + i = 0; + } + } + } + @override Iterator get iterator { _garbageCollect(); diff --git a/packages/flame/lib/src/game/flame_game.dart b/packages/flame/lib/src/game/flame_game.dart index 0115cefee4d..471f556aa0f 100644 --- a/packages/flame/lib/src/game/flame_game.dart +++ b/packages/flame/lib/src/game/flame_game.dart @@ -233,6 +233,13 @@ class FlameGame extends ComponentTreeRoot /// then forget to mount `x` into the game. @override Future ready() async { + while (isProcessingLifecycleEvents) { + // This call came from inside a lifecycle callback, which runs while + // [processLifecycleEvents] is iterating over the event queue. Since + // the queue only supports one iteration at a time, wait until the + // current processing pass has finished. + await null; + } var wake = Completer(); void wakeUp() { if (!wake.isCompleted) { diff --git a/packages/flame/lib/src/game/game.dart b/packages/flame/lib/src/game/game.dart index 7ba3ced38d9..216ed88dd0b 100644 --- a/packages/flame/lib/src/game/game.dart +++ b/packages/flame/lib/src/game/game.dart @@ -142,6 +142,9 @@ abstract mixin class Game { /// mounted, before the game is shown and the first update tick runs. By /// default it completes immediately; [FlameGame] overrides this to wait /// until the whole initial component tree has been loaded and mounted. + /// + /// Since this future is awaited on the startup critical path, an override + /// that never completes keeps the game on the loading widget forever. Future ready() async {} @mustCallSuper diff --git a/packages/flame/lib/src/game/game_widget/game_widget.dart b/packages/flame/lib/src/game/game_widget/game_widget.dart index b25283f857b..96adf4fc6cf 100644 --- a/packages/flame/lib/src/game/game_widget/game_widget.dart +++ b/packages/flame/lib/src/game/game_widget/game_widget.dart @@ -210,9 +210,10 @@ class GameWidgetState extends State> { Future get loaderFuture => _loaderFuture ??= (() async { final game = currentGame; + final gameGeneration = _gameGeneration; assert(game.hasLayout); await game.load(); - if (_isStale(game)) { + if (_isStale(gameGeneration)) { return; } game.mount(); @@ -220,7 +221,7 @@ class GameWidgetState extends State> { // the game does not start, and the loading widget is not removed, // until every component added during the initial load is ready. await game.ready(); - if (_isStale(game)) { + if (_isStale(gameGeneration)) { return; } if (!game.isPaused) { @@ -228,10 +229,16 @@ class GameWidgetState extends State> { } })(); - /// Whether [game] is no longer the game this widget state is showing, + /// Whether the loader that captured [gameGeneration] is no longer current, /// either because the widget was disposed or because the game instance was - /// swapped out while the game was still loading. - bool _isStale(T game) => !mounted || !identical(game, currentGame); + /// swapped since then. A generation counter is used instead of comparing + /// game identities, so that swapping to another game and back to the + /// original one while it is still loading also invalidates the old loader. + bool _isStale(int gameGeneration) => + !mounted || gameGeneration != _gameGeneration; + + /// Incremented every time [initCurrentGame] installs a game instance. + int _gameGeneration = 0; Future? _loaderFuture; @@ -286,6 +293,7 @@ class GameWidgetState extends State> { currentGame = widget.game!; } initGameStateListener(currentGame, _onGameStateChange); + _gameGeneration++; _loaderFuture = null; } diff --git a/packages/flame/test/game/flame_game_test.dart b/packages/flame/test/game/flame_game_test.dart index 4963cfc5319..e792ba71aad 100644 --- a/packages/flame/test/game/flame_game_test.dart +++ b/packages/flame/test/game/flame_game_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:ui'; import 'package:collection/collection.dart'; @@ -316,6 +317,20 @@ void main() { }); }); + group('ready:', () { + testWithFlameGame( + 'can be called from inside a lifecycle callback', + (game) async { + final component = _ReadyingOnMountComponent(); + game.world.add(component); + await game.ready(); + + expect(component.isMounted, isTrue); + expect(game.hasLifecycleEvents, isFalse); + }, + ); + }); + group('pauseWhenBackgrounded:', () { testWidgets( 'game resumes when widget is rebuilt', @@ -528,6 +543,13 @@ class _MyAsyncComponent extends _MyComponent { } } +class _ReadyingOnMountComponent extends Component { + @override + void onMount() { + unawaited(findGame()!.ready()); + } +} + class _OnAttachGame extends FlameGame { final VoidCallback onAttachCallback; diff --git a/packages/flame/test/game/game_widget/game_widget_test.dart b/packages/flame/test/game/game_widget/game_widget_test.dart index 24b1dfb0621..b7f9f18a3f1 100644 --- a/packages/flame/test/game/game_widget/game_widget_test.dart +++ b/packages/flame/test/game/game_widget/game_widget_test.dart @@ -522,11 +522,33 @@ void main() { await tester.pump(); expect(slowGame.isMounted, isFalse); - expect(slowGame.onMountCalled, isFalse); + expect(slowGame.onMountCount, 0); expect(quickGame.isAttached, isTrue); }, ); + testWidgets( + 'swapping away and back during loading only mounts the game once', + (tester) async { + const key = Key('flame-game'); + final loadGate = Completer(); + final slowGame = _GatedLoadGame(loadGate); + await tester.pumpWidget(GameWidget(key: key, game: slowGame)); + + final otherGame = FlameGame(); + await tester.pumpWidget(GameWidget(key: key, game: otherGame)); + await tester.pumpWidget(GameWidget(key: key, game: slowGame)); + + loadGate.complete(); + await tester.pump(); + await tester.pump(); + + expect(slowGame.isMounted, isTrue); + expect(slowGame.onMountCount, 1); + expect(slowGame.isAttached, isTrue); + }, + ); + testWidgets( 'game with a synchronously loaded tree starts fully mounted', (tester) async { @@ -611,14 +633,14 @@ class _GatedLoadGame extends FlameGame { _GatedLoadGame(this.loadGate); final Completer loadGate; - bool onMountCalled = false; + int onMountCount = 0; @override Future onLoad() => loadGate.future; @override void onMount() { - onMountCalled = true; + onMountCount++; } } diff --git a/packages/flame/test/math/recycled_queue_test.dart b/packages/flame/test/math/recycled_queue_test.dart index 31321cc01ad..14ae7b15a92 100644 --- a/packages/flame/test/math/recycled_queue_test.dart +++ b/packages/flame/test/math/recycled_queue_test.dart @@ -125,6 +125,52 @@ void main() { } }); + group('firstWhereOrNull', () { + test('returns the first match without disturbing iteration', () { + final queue = RecycledQueue(_Int.new, initialCapacity: 2); + queue.addLast().value = 1; + queue.addLast().value = 2; + queue.addLast().value = 3; + queue.addLast().value = 2; + + expect(queue.firstWhereOrNull((element) => element.value == 5), null); + + final visited = []; + for (final element in queue) { + visited.add(element.value); + // Can be called mid-iteration without resetting the iterator. + final match = queue.firstWhereOrNull( + (element) => element.value == 2, + ); + expect(match, _Int(2)); + } + expect(visited, [1, 2, 3, 2]); + }); + + test('returns null for an empty queue', () { + final queue = RecycledQueue(_Int.new, initialCapacity: 0); + expect(queue.firstWhereOrNull((element) => true), null); + }); + + test('works on a wrapped-around queue', () { + final queue = RecycledQueue(_Int.new, initialCapacity: 4); + for (var i = 0; i < 4; i++) { + queue.addLast().value = i; + } + queue.removeFirst(); + queue.removeFirst(); + queue.addLast().value = 4; + queue.addLast().value = 5; + + expect(queue.toList(), [2, 3, 4, 5].map(_Int.new)); + expect( + queue.firstWhereOrNull((element) => element.value == 5), + _Int(5), + ); + expect(queue.firstWhereOrNull((element) => element.value == 0), null); + }); + }); + group('iteration', () { test('iterate over an empty queue', () { final queue1 = RecycledQueue(_Int.new, initialCapacity: 0); diff --git a/packages/flame_test/lib/src/test_flame_game.dart b/packages/flame_test/lib/src/test_flame_game.dart index 374c8a71fc6..9af7113dab3 100644 --- a/packages/flame_test/lib/src/test_flame_game.dart +++ b/packages/flame_test/lib/src/test_flame_game.dart @@ -99,8 +99,7 @@ Future initializeGame(CreateFunction create) async { await game.load(); // ignore: invalid_use_of_internal_member game.mount(); - // Wait for the whole initial component tree to be loaded and mounted, the - // same way the GameWidget does before starting the game. + // The same startup sequence as the GameWidget uses; see [FlameGame.ready]. await game.ready(); game.update(0); return game;