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..7b81e825a35 100644 --- a/packages/flame/benchmark/common.dart +++ b/packages/flame/benchmark/common.dart @@ -13,5 +13,6 @@ Future mountGame(FlameGame game, {Vector2? size}) async { await game.load(); // ignore: invalid_use_of_internal_member game.mount(); + 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..510a404b746 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,22 @@ 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() { + _lifecycleEventMutationCompleter?.complete(); + _lifecycleEventMutationCompleter = null; + } @internal void enqueueAdd(Component child, Component parent) { @@ -33,21 +49,28 @@ 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.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. + final event = queue.firstWhereOrNull( + (event) => + event.kind == LifecycleEventKind.add && event.child == child && - event.parent == parent) { - event.kind = LifecycleEventKind.unknown; - return; - } - } - throw AssertionError( - 'Cannot find a lifecycle event Add(child=$child, parent=$parent)', + event.parent == parent, ); + if (event == null) { + throw AssertionError( + 'Cannot find a lifecycle event Add(child=$child, parent=$parent)', + ); + } + event.kind = LifecycleEventKind.unknown; + _notifyLifecycleEventMutation(); } @internal @@ -56,14 +79,23 @@ 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. + var dequeuedAny = false; + queue.forEachWhere( + (event) => + event.kind == LifecycleEventKind.remove && event.child == child, + (event) { event.kind = LifecycleEventKind.unknown; - } + dequeuedAny = true; + }, + ); + if (dequeuedAny) { + _notifyLifecycleEventMutation(); } } @@ -86,6 +118,9 @@ class ComponentTreeRoot extends Component { event.kind = LifecycleEventKind.unknown; }, ); + if (result.isNotEmpty) { + _notifyLifecycleEventMutation(); + } } @internal @@ -94,6 +129,7 @@ class ComponentTreeRoot extends Component { ..kind = LifecycleEventKind.move ..child = child ..parent = newParent; + _notifyLifecycleEventMutation(); } @internal @@ -105,6 +141,7 @@ class ComponentTreeRoot extends Component { ..kind = LifecycleEventKind.rebalance ..child = child ..parent = parent; + _notifyLifecycleEventMutation(); } bool get hasLifecycleEvents => queue.isNotEmpty; @@ -142,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 729e2c71ebf..471f556aa0f 100644 --- a/packages/flame/lib/src/game/flame_game.dart +++ b/packages/flame/lib/src/game/flame_game.dart @@ -216,21 +216,67 @@ 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; + 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) { + 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..216ed88dd0b 100644 --- a/packages/flame/lib/src/game/game.dart +++ b/packages/flame/lib/src/game/game.dart @@ -136,6 +136,17 @@ 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. + /// + /// 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 @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..96adf4fc6cf 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 @@ -206,14 +210,36 @@ class GameWidgetState extends State> { Future get loaderFuture => _loaderFuture ??= (() async { final game = currentGame; + final gameGeneration = _gameGeneration; assert(game.hasLayout); await game.load(); + if (_isStale(gameGeneration)) { + 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(gameGeneration)) { + return; + } if (!game.isPaused) { game.update(0); } })(); + /// Whether the loader that captured [gameGeneration] is no longer current, + /// either because the widget was disposed or because the game instance was + /// 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; late FocusNode _focusNode; @@ -267,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 055ccddf3bf..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'; @@ -306,6 +307,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); @@ -313,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', @@ -525,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 e6a5fb332d9..b7f9f18a3f1 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,144 @@ 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.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 { + 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 +607,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; + int onMountCount = 0; + + @override + Future onLoad() => loadGate.future; + + @override + void onMount() { + onMountCount++; + } +} + class _GameWithBuildContextCheck extends FlameGame { BuildContext? buildContextDuringOnLoad; 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 9fc0afc08e2..9af7113dab3 100644 --- a/packages/flame_test/lib/src/test_flame_game.dart +++ b/packages/flame_test/lib/src/test_flame_game.dart @@ -99,6 +99,8 @@ Future initializeGame(CreateFunction create) async { await game.load(); // ignore: invalid_use_of_internal_member game.mount(); + // The same startup sequence as the GameWidget uses; see [FlameGame.ready]. + await game.ready(); game.update(0); return game; }