mirror of
				https://github.com/flame-engine/flame.git
				synced 2025-11-01 01:18:38 +08:00 
			
		
		
		
	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:
		| @ -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. | ||||||
|  | |||||||
							
								
								
									
										
											BIN
										
									
								
								examples/assets/images/Car.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								examples/assets/images/Car.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 195 B | 
							
								
								
									
										141
									
								
								examples/lib/stories/system/step_engine_example.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										141
									
								
								examples/lib/stories/system/step_engine_example.dart
									
									
									
									
									
										Normal 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(__); | ||||||
|  |   } | ||||||
|  | } | ||||||
| @ -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, | ||||||
|     ); |     ); | ||||||
| } | } | ||||||
|  | |||||||
| @ -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. | ||||||
|  | |||||||
| @ -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 | ||||||
|  | |||||||
							
								
								
									
										40
									
								
								packages/flame/test/game/game_loop_test.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								packages/flame/test/game/game_loop_test.dart
									
									
									
									
									
										Normal 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); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | } | ||||||
		Reference in New Issue
	
	Block a user
	 DevKage
					DevKage