Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat!: Pause game when backgrounded #2642

Merged
merged 5 commits into from
Sep 21, 2023
Merged
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
3 changes: 3 additions & 0 deletions .github/.cspell/gamedev_dictionary.txt
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ arial # name of a typeface
arities # plural of arity
arity # number of parameters a function takes
autofocus # auto focus event
backgrounded # moving the app to the background
backgrounding # moving the app to the background
backpressure # strategy to deal with excess flow of data
backquote # another word for backtick
backtick # the back tick character "`"
Expand All @@ -39,6 +41,7 @@ coord # coordinate
coords # plural of coord
deduplication # removal of duplicates
easings # Easing functions specify the rate of change of a parameter over time
foregrounded # moving the app to the foreground
fullscreen # mode in which a program or app occupies the entire screen with no borders
goldens # test files used as reference for Golden Tests
hardcoding # putting a value as a literal instead of computing it
Expand Down
18 changes: 18 additions & 0 deletions doc/flame/game.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,3 +226,21 @@ While the game is paused, it is possible to advanced it frame by frame using the
method.
It might not be much useful in the final game, but can be very helpful in inspecting game state step
by step during the development cycle.


### Backgrounding

The game will be automatically paused when the app is sent to the background,
and resumed when it comes back to the foreground. This behavior can be disabled by setting
`pauseWhenBackgrounded` to `false`.

```dart
class MyGame extends FlameGame {
MyGame() {
pauseWhenBackgrounded = false;
}
}
```

On the current Flutter stable (3.13), this flag is effectively ignored on
non-mobile platforms including the web.
40 changes: 40 additions & 0 deletions packages/flame/lib/src/game/flame_game.dart
Original file line number Diff line number Diff line change
Expand Up @@ -255,4 +255,44 @@ class FlameGame<W extends World> extends ComponentTreeRoot
}
}
}

/// Whether the game should pause when the app is backgrounded.
///
/// On the latest Flutter stable at the time of writing (3.13),
/// this is only working on Android and iOS.
///
/// Defaults to true.
bool pauseWhenBackgrounded = true;
bool _pausedBecauseBackgrounded = false;

@override
@mustCallSuper
void lifecycleStateChange(AppLifecycleState state) {
switch (state) {
case AppLifecycleState.resumed:
case AppLifecycleState.inactive:
if (_pausedBecauseBackgrounded) {
resumeEngine();
}
case AppLifecycleState.paused:
case AppLifecycleState.detached:
case AppLifecycleState.hidden:
if (pauseWhenBackgrounded && !paused) {
pauseEngine();
_pausedBecauseBackgrounded = true;
}
}
}

@override
void pauseEngine() {
_pausedBecauseBackgrounded = false;
super.pauseEngine();
}

@override
void resumeEngine() {
_pausedBecauseBackgrounded = false;
super.resumeEngine();
}
}
4 changes: 4 additions & 0 deletions packages/flame/lib/src/game/game_widget/game_widget.dart
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,9 @@ class GameWidgetState<T extends Game> extends State<GameWidget<T>> {
currentGame = widget.game!;
}
currentGame.addGameStateListener(_onGameStateChange);
currentGame.lifecycleStateChange(
Copy link
Member

Choose a reason for hiding this comment

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

Hmm, it's pretty weird to call these manually since these state changes should be coming from the framework.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think it makes more sense for the game to act identically to if the app is backgrounded when really it was just unmounted.
This makes the link between "backgrounding the app" and "backgrounding the game (widget)"

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Plus as an added bonus, it makes the behaviour more consistent on non-mobile platforms since the game will be paused when the widget is disposed of

Copy link
Member

Choose a reason for hiding this comment

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

I see what you mean, it feels like the state machine could become inconsistent with the one on Flutter's side though, which might confuse developers and be bug prone.

WidgetsBinding.instance.lifecycleState ?? AppLifecycleState.resumed,
);
_loaderFuture = null;
}

Expand All @@ -262,6 +265,7 @@ class GameWidgetState<T extends Game> extends State<GameWidget<T>> {
/// `currentGame`'s `onDispose` method will be called; otherwise, it will not.
void disposeCurrentGame({bool callGameOnDispose = false}) {
currentGame.removeGameStateListener(_onGameStateChange);
currentGame.lifecycleStateChange(AppLifecycleState.paused);
currentGame.onRemove();
if (callGameOnDispose) {
currentGame.onDispose();
Expand Down
80 changes: 70 additions & 10 deletions packages/flame/test/game/flame_game_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import 'package:flame/game.dart';
import 'package:flame/src/events/flame_game_mixins/multi_tap_dispatcher.dart';
import 'package:flame/src/game/game_render_box.dart';
import 'package:flame_test/flame_test.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';

import '../components/component_test.dart';
Expand Down Expand Up @@ -47,8 +47,8 @@ void main() {
await game.ready();

expect(innerGame.canvasSize, closeToVector(Vector2(800, 600)));
expect(innerGame.isLoaded, true);
expect(innerGame.isMounted, true);
expect(innerGame.isLoaded, isTrue);
expect(innerGame.isMounted, isTrue);
});

group('components', () {
Expand All @@ -58,8 +58,8 @@ void main() {
final component = Component();
await game.ensureAdd(component);

expect(component.isMounted, true);
expect(game.children.contains(component), true);
expect(component.isMounted, isTrue);
expect(game.children.contains(component), isTrue);
},
);

Expand All @@ -69,7 +69,7 @@ void main() {
final component = _MyAsyncComponent();
await game.ensureAdd(component);

expect(game.children.contains(component), true);
expect(game.children.contains(component), isTrue);
expect(component.gameSize, game.size);
expect(component.game, game);
},
Expand Down Expand Up @@ -128,12 +128,12 @@ void main() {
await game.add(component);
renderBox.gameLoopCallback(1.0);

expect(component.isUpdateCalled, true);
expect(component.isUpdateCalled, isTrue);
renderBox.paint(
PaintingContext(ContainerLayer(), Rect.zero),
Offset.zero,
);
expect(component.isRenderCalled, true);
expect(component.isRenderCalled, isTrue);
renderBox.detach();
},
);
Expand Down Expand Up @@ -165,7 +165,7 @@ void main() {
expect(world.children.length, equals(1));
component.removeFromParent();
game.updateTree(0);
expect(world.children.isEmpty, equals(true));
expect(world.children.isEmpty, equals(isTrue));
},
);

Expand All @@ -175,7 +175,7 @@ void main() {
final game = FlameGame();
final world = game.world;
final component = Component()..addToParent(world);
expect(game.hasLayout, false);
expect(game.hasLayout, isFalse);

await tester.pumpWidget(GameWidget(game: game));
game.update(0);
Expand Down Expand Up @@ -698,6 +698,66 @@ void main() {
});
});
});

group('pauseWhenBackgrounded:', () {
Copy link
Member

Choose a reason for hiding this comment

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

Can you add some tests where it is using a widget and not calling lifecycleStateChange manually? There should be some other widget tests that you can take inspiration from.

Copy link
Member

Choose a reason for hiding this comment

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

I'll add some tests, so that we can get this PR into the release.

testWithFlameGame('true', (game) async {
game.pauseWhenBackgrounded = true;

game.lifecycleStateChange(AppLifecycleState.paused);
expect(game.paused, isTrue);

game.lifecycleStateChange(AppLifecycleState.resumed);
expect(game.paused, isFalse);
});

testWithFlameGame('false', (game) async {
game.pauseWhenBackgrounded = false;

game.lifecycleStateChange(AppLifecycleState.paused);
expect(game.paused, isFalse);

game.lifecycleStateChange(AppLifecycleState.resumed);
expect(game.paused, isFalse);
});

testWidgets(
'game is not paused on start',
(tester) async {
final game = FlameGame();

await tester.pumpWidget(
GameWidget(game: game),
);

await game.toBeLoaded();
await tester.pump();

expect(game.paused, isFalse);
},
);

testWidgets(
'game is paused when app is backgrounded',
(tester) async {
final game = FlameGame();

await tester.pumpWidget(GameWidget(game: game));

await game.toBeLoaded();
await tester.pump();

expect(game.paused, isFalse);
WidgetsBinding.instance.handleAppLifecycleStateChanged(
AppLifecycleState.paused,
);
expect(game.paused, isTrue);
WidgetsBinding.instance.handleAppLifecycleStateChanged(
AppLifecycleState.resumed,
);
expect(game.paused, isFalse);
},
);
});
});
}

Expand Down