Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions doc/flame/game.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/flame/benchmark/common.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,6 @@ Future<void> mountGame(FlameGame game, {Vector2? size}) async {
await game.load();
// ignore: invalid_use_of_internal_member
game.mount();
await game.ready();
game.update(0);
}
30 changes: 25 additions & 5 deletions packages/flame/lib/src/components/core/component.dart
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ class Component {
void _clearRemovedBit() => _state &= ~_removed;

Completer<void>? _loadCompleter;
Completer<void>? _loadSettledCompleter;
Completer<void>? _mountCompleter;
Completer<void>? _removeCompleter;

Expand All @@ -244,6 +245,22 @@ class Component {
: (_loadCompleter ??= Completer<void>()).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<void> get loadSettled {
if (isLoaded || _loadError != null) {
return Future.value();
}
return (_loadSettledCompleter ??= Completer<void>()).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
Expand Down Expand Up @@ -1061,6 +1078,12 @@ class Component {
_setLoadedBit();
_loadCompleter?.complete();
_loadCompleter = null;
_completeLoadSettled();
}

void _completeLoadSettled() {
_loadSettledCompleter?.complete();
_loadSettledCompleter = null;
}

/// Surfaces an error thrown by [onLoad].
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
84 changes: 72 additions & 12 deletions packages/flame/lib/src/components/core/component_tree_root.dart
Original file line number Diff line number Diff line change
Expand Up @@ -26,28 +26,51 @@ class ComponentTreeRoot extends Component {
final Set<Component> _blocked;
late final Map<ComponentKey, Component> _index = {};
Completer<void>? _lifecycleEventsCompleter;
Completer<void>? _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<void> get nextLifecycleEventMutation =>
(_lifecycleEventMutationCompleter ??= Completer<void>()).future;

void _notifyLifecycleEventMutation() {
_lifecycleEventMutationCompleter?.complete();
_lifecycleEventMutationCompleter = null;
}

@internal
void enqueueAdd(Component child, Component parent) {
queue.addLast()
..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
Expand All @@ -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();
}
}

Expand All @@ -86,6 +118,9 @@ class ComponentTreeRoot extends Component {
event.kind = LifecycleEventKind.unknown;
},
);
if (result.isNotEmpty) {
_notifyLifecycleEventMutation();
}
}

@internal
Expand All @@ -94,6 +129,7 @@ class ComponentTreeRoot extends Component {
..kind = LifecycleEventKind.move
..child = child
..parent = newParent;
_notifyLifecycleEventMutation();
}

@internal
Expand All @@ -105,6 +141,7 @@ class ComponentTreeRoot extends Component {
..kind = LifecycleEventKind.rebalance
..child = child
..parent = parent;
_notifyLifecycleEventMutation();
}

bool get hasLifecycleEvents => queue.isNotEmpty;
Expand Down Expand Up @@ -142,7 +179,30 @@ class ComponentTreeRoot extends Component {
: (_lifecycleEventsCompleter ??= Completer<void>()).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 = <Component>{};
LifecycleEventStatus handleReorderEvent(Component parent) {
Expand Down
22 changes: 22 additions & 0 deletions packages/flame/lib/src/components/core/recycled_queue.dart
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,28 @@ class RecycledQueue<T extends Disposable> extends Iterable<T>
}
}

/// 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<T> get iterator {
_garbageCollect();
Expand Down
68 changes: 57 additions & 11 deletions packages/flame/lib/src/game/flame_game.dart
Original file line number Diff line number Diff line change
Expand Up @@ -216,21 +216,67 @@ class FlameGame<W extends World> 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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what are x and y here, is x the game, or x and y are just two random components that hadn't been added to the game?

/// then forget to mount `x` into the game.
@override
Future<void> ready() async {
var repeat = true;
while (repeat) {
// Give chance to other futures to execute first
await Future<void>.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>();
void wakeUp() {
if (!wake.isCompleted) {
wake.complete();
}
}

final watchedChildren = <Component>{};
while (hasLifecycleEvents) {
processLifecycleEvents();
repeat |= hasLifecycleEvents;
if (!hasLifecycleEvents) {
break;
}
if (wake.isCompleted) {
wake = Completer<void>();
}
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<void>.delayed(Duration.zero);
}
}
}

Expand Down
11 changes: 11 additions & 0 deletions packages/flame/lib/src/game/game.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> ready() async {}

@mustCallSuper
@internal
void finalizeRemoval() {
Expand Down
Loading
Loading