feat: Add stepEngine to Game (#2516)

This PR adds a new method to Game which allows advancing the game loop by a certain amount of time while the engine is paused. By default it assumes one frame to be ~16 ms, but it can be controlled while calling stepEngine

The idea is to allow easy frame by frame inspection of the game. It can even be added to FlameStudio as part of the start/pause buttons on the toolbar.

https://user-images.githubusercontent.com/33748002/233453501-b9f90d49-1834-4f0f-9536-77629cfcadbc.mp4
This commit is contained in:
DevKage
2023-04-24 00:58:37 +05:30
committed by GitHub
parent 9ab1adec1b
commit 1ed2c5a297
7 changed files with 211 additions and 1 deletions

View File

@ -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: 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 When pausing a Flame `Game`, the `GameLoop` is effectively paused, meaning that no updates or new
renders will happen until it is resumed. 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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 B

View File

@ -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<void> 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<LogicalKeyboardKey> 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<Component> _createCircularDetectors() {
final componentsToAdd = <Component>[];
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(__);
}
}

View File

@ -2,6 +2,7 @@ import 'package:dashbook/dashbook.dart';
import 'package:examples/commons/commons.dart'; import 'package:examples/commons/commons.dart';
import 'package:examples/stories/system/overlays_example.dart'; import 'package:examples/stories/system/overlays_example.dart';
import 'package:examples/stories/system/pause_resume_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:examples/stories/system/without_flamegame_example.dart';
import 'package:flame/game.dart'; import 'package:flame/game.dart';
@ -24,5 +25,11 @@ void addSystemStories(Dashbook dashbook) {
(_) => GameWidget(game: NoFlameGameExample()), (_) => GameWidget(game: NoFlameGameExample()),
codeLink: baseLink('system/without_flamegame_example.dart'), codeLink: baseLink('system/without_flamegame_example.dart'),
info: NoFlameGameExample.description, info: NoFlameGameExample.description,
)
..add(
'Step Game',
(_) => GameWidget(game: StepEngineExample()),
codeLink: baseLink('system/step_engine_game.dart'),
info: StepEngineExample.description,
); );
} }

View File

@ -307,6 +307,16 @@ abstract class Game {
_gameRenderBox?.gameLoop?.start(); _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] /// A property that stores an [OverlayManager]
/// ///
/// This is useful to render widgets on top of a game, such as a pause menu. /// This is useful to render widgets on top of a game, such as a pause menu.

View File

@ -63,6 +63,14 @@ class GameLoop {
_previous = Duration.zero; _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. /// Call this before deleting the [GameLoop] object.
/// ///
/// The [GameLoop] will no longer be usable after this method is called. You /// The [GameLoop] will no longer be usable after this method is called. You

View File

@ -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);
});
});
}