diff --git a/doc/flame/game.md b/doc/flame/game.md index 97fcc6604..2309cdf3f 100644 --- a/doc/flame/game.md +++ b/doc/flame/game.md @@ -189,7 +189,7 @@ void main() { ``` -## Pause/Resuming game execution +## Pause/Resuming/Stepping game execution A Flame `Game` can be paused and resumed in two ways: @@ -198,3 +198,7 @@ A Flame `Game` can be paused and resumed in two ways: When pausing a Flame `Game`, the `GameLoop` is effectively paused, meaning that no updates or new renders will happen until it is resumed. + +While the game is paused, it is possible to advanced it frame by frame using the `stepEngine` 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. diff --git a/examples/assets/images/Car.png b/examples/assets/images/Car.png new file mode 100644 index 000000000..163692105 Binary files /dev/null and b/examples/assets/images/Car.png differ diff --git a/examples/lib/stories/system/step_engine_example.dart b/examples/lib/stories/system/step_engine_example.dart new file mode 100644 index 000000000..da6ee9d6c --- /dev/null +++ b/examples/lib/stories/system/step_engine_example.dart @@ -0,0 +1,141 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:flame/collisions.dart'; +import 'package:flame/components.dart'; +import 'package:flame/effects.dart'; +import 'package:flame/events.dart'; +import 'package:flame/game.dart'; +import 'package:flame/palette.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class StepEngineExample extends FlameGame + with HasCollisionDetection, HasKeyboardHandlerComponents { + static const description = ''' + This example demonstrates how the game can be advanced frame by frame using + stepEngine method. + + To pause and un-pause the game anytime press the `P` key. Once paused, use + the `S` key to step by one frame. + + Up arrow and down arrow can be used to increase or decrease the step time. + '''; + + // Fixed resolution of the game. + static final Vector2 _visibleSize = Vector2(320, 180); + double _stepTimeMultiplier = 1; + static const _stepTime = 1 / 60; + + @override + Color backgroundColor() => BasicPalette.darkGreen.color; + + @override + Future onLoad() async { + final carSprite = await Sprite.load('Car.png'); + final car = SpriteComponent( + sprite: carSprite, + anchor: Anchor.center, + angle: -pi / 10, + position: Vector2(0, _visibleSize.y / 3), + children: [CircleHitbox()], + ); + + final world = World( + children: [ + ..._createCircularDetectors(), + PositionComponent(children: [car, _rotateEffect]), + ], + ); + + final cameraComponent = CameraComponent.withFixedResolution( + world: world, + width: _visibleSize.x, + height: _visibleSize.y, + hudComponents: [_controlsText], + ); + + await addAll([world, cameraComponent]); + } + + @override + KeyEventResult onKeyEvent(_, Set keysPressed) { + if (keysPressed.contains(LogicalKeyboardKey.keyP)) { + paused = !paused; + } else if (keysPressed.contains(LogicalKeyboardKey.keyS)) { + stepEngine(stepTime: _stepTime * _stepTimeMultiplier); + } else if (keysPressed.contains(LogicalKeyboardKey.arrowUp)) { + _stepTimeMultiplier += 1; + _controlsText.text = _text; + } else if (keysPressed.contains(LogicalKeyboardKey.arrowDown)) { + _stepTimeMultiplier -= 1; + _controlsText.text = _text; + } + return super.onKeyEvent(_, keysPressed); + } + + // Creates the circle detectors. + List _createCircularDetectors() { + final componentsToAdd = []; + final offsetVec = Vector2(0, -_visibleSize.y / 2.5); + for (var i = 0; i < 12; ++i) { + offsetVec.rotate(2 * pi / 12); + componentsToAdd.add( + _DetectorComponents( + radius: 5, + position: offsetVec, + anchor: Anchor.center, + children: [CircleHitbox()], + ), + ); + } + return componentsToAdd; + } + + final _rotateEffect = RotateEffect.by( + 2 * pi, + InfiniteEffectController( + SpeedEffectController( + LinearEffectController(1), + speed: 1, + ), + ), + ); + + String get _text => + 'P: Pause/Unpause\nS: Step x$_stepTimeMultiplier\nUp: Increase step\nDown: Decrease step'; + + late final _controlsText = TextBoxComponent( + text: _text, + textRenderer: TextPaint( + style: TextStyle( + color: BasicPalette.white.color, + fontSize: 20.0, + shadows: const [ + Shadow(offset: Offset(1, 1), blurRadius: 1), + ], + ), + ), + ); +} + +class _DetectorComponents extends CircleComponent with CollisionCallbacks { + _DetectorComponents({ + super.radius, + super.position, + super.anchor, + super.children, + }); + + @override + void onCollisionStart(_, __) { + paint.color = BasicPalette.black.color; + super.onCollisionStart(_, __); + } + + @override + void onCollisionEnd(__) { + paint.color = BasicPalette.white.color; + super.onCollisionEnd(__); + } +} diff --git a/examples/lib/stories/system/system.dart b/examples/lib/stories/system/system.dart index e294cf134..a2ea6ec9a 100644 --- a/examples/lib/stories/system/system.dart +++ b/examples/lib/stories/system/system.dart @@ -2,6 +2,7 @@ import 'package:dashbook/dashbook.dart'; import 'package:examples/commons/commons.dart'; import 'package:examples/stories/system/overlays_example.dart'; import 'package:examples/stories/system/pause_resume_example.dart'; +import 'package:examples/stories/system/step_engine_example.dart'; import 'package:examples/stories/system/without_flamegame_example.dart'; import 'package:flame/game.dart'; @@ -24,5 +25,11 @@ void addSystemStories(Dashbook dashbook) { (_) => GameWidget(game: NoFlameGameExample()), codeLink: baseLink('system/without_flamegame_example.dart'), info: NoFlameGameExample.description, + ) + ..add( + 'Step Game', + (_) => GameWidget(game: StepEngineExample()), + codeLink: baseLink('system/step_engine_game.dart'), + info: StepEngineExample.description, ); } diff --git a/packages/flame/lib/src/game/game.dart b/packages/flame/lib/src/game/game.dart index a94993bbc..d72686e91 100644 --- a/packages/flame/lib/src/game/game.dart +++ b/packages/flame/lib/src/game/game.dart @@ -307,6 +307,16 @@ abstract class Game { _gameRenderBox?.gameLoop?.start(); } + /// Steps the engine game loop by one frame. Works only if the engine is in + /// paused state. By default step time is assumed to be 1/60th of a second. + void stepEngine({double stepTime = 1 / 60}) { + if (_paused) { + _paused = false; + _gameRenderBox?.gameLoop?.step(stepTime); + _paused = true; + } + } + /// A property that stores an [OverlayManager] /// /// This is useful to render widgets on top of a game, such as a pause menu. diff --git a/packages/flame/lib/src/game/game_loop.dart b/packages/flame/lib/src/game/game_loop.dart index 8a8e441b6..978282cac 100644 --- a/packages/flame/lib/src/game/game_loop.dart +++ b/packages/flame/lib/src/game/game_loop.dart @@ -63,6 +63,14 @@ class GameLoop { _previous = Duration.zero; } + /// Steps the game loop by the given amount of time while the ticker is + /// stopped. + void step(double stepTime) { + if (!_ticker.isActive) { + callback(stepTime); + } + } + /// Call this before deleting the [GameLoop] object. /// /// The [GameLoop] will no longer be usable after this method is called. You diff --git a/packages/flame/test/game/game_loop_test.dart b/packages/flame/test/game/game_loop_test.dart new file mode 100644 index 000000000..751e487b0 --- /dev/null +++ b/packages/flame/test/game/game_loop_test.dart @@ -0,0 +1,40 @@ +import 'package:flame/src/game/game_loop.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('GameLoop step', () { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('works when game loop is paused', () { + var tickCount = 0; + final gameLoop = GameLoop((dt) => ++tickCount); + + gameLoop.step(1); + expect(tickCount, 1); + }); + + test('does not work when game loop is active', () { + var tickCount = 0; + final gameLoop = GameLoop((dt) => ++tickCount); + gameLoop.start(); + + gameLoop.step(1); + expect(tickCount, 0); + }); + + test('overrides step time correctly', () { + var elapsedTime = 0.0; + const frameTime30 = 1 / 30; + const frameTime120 = 1 / 120; + + final gameLoop = GameLoop((dt) => elapsedTime += dt); + + gameLoop.step(frameTime30); + expectDouble(elapsedTime, frameTime30); + + gameLoop.step(frameTime120); + expectDouble(elapsedTime, frameTime30 + frameTime120); + }); + }); +}