diff --git a/doc/flame/examples/lib/main.dart b/doc/flame/examples/lib/main.dart index 482795ed7..c987a44e2 100644 --- a/doc/flame/examples/lib/main.dart +++ b/doc/flame/examples/lib/main.dart @@ -30,6 +30,7 @@ import 'package:doc_flame_examples/sequence_effect.dart'; import 'package:doc_flame_examples/size_by_effect.dart'; import 'package:doc_flame_examples/size_to_effect.dart'; import 'package:doc_flame_examples/tap_events.dart'; +import 'package:doc_flame_examples/time_scale.dart'; import 'package:doc_flame_examples/value_route.dart'; import 'package:flame/game.dart'; import 'package:flutter/widgets.dart'; @@ -71,6 +72,7 @@ void main() { 'glow_effect': GlowEffectExample.new, 'remove_effect': RemoveEffectGame.new, 'color_effect': ColorEffectExample.new, + 'time_scale': TimeScaleGame.new, }; final game = routes[page]?.call(); if (game != null) { diff --git a/doc/flame/examples/lib/time_scale.dart b/doc/flame/examples/lib/time_scale.dart new file mode 100644 index 000000000..3c58878fa --- /dev/null +++ b/doc/flame/examples/lib/time_scale.dart @@ -0,0 +1,31 @@ +import 'dart:async'; + +import 'package:doc_flame_examples/ember.dart'; +import 'package:flame/components.dart'; +import 'package:flame/experimental.dart'; +import 'package:flame/game.dart'; + +class TimeScaleGame extends FlameGame with HasTimeScale, HasTappableComponents { + final _timeScales = [0.5, 1.0, 2.0]; + var _index = 1; + + @override + Future onLoad() async { + await add( + EmberPlayer( + position: size / 2, + size: size / 4, + onTap: (p0) => timeScale = getNextTimeScale(), + ), + ); + return super.onLoad(); + } + + double getNextTimeScale() { + ++_index; + if (_index >= _timeScales.length) { + _index = 0; + } + return _timeScales[_index]; + } +} diff --git a/doc/flame/other/util.md b/doc/flame/other/util.md index 4a2442e91..1d9146160 100644 --- a/doc/flame/other/util.md +++ b/doc/flame/other/util.md @@ -146,6 +146,45 @@ class MyFlameGame extends FlameGame { ``` +## Time Scale + +In many games it is often desirable to create slow-motion or fast-forward effects based on some in +game events. A very common approach to achieve these results is to manipulate the in game time or +tick rate. + +To make this manipulation easier, Flame provides a `HasTimeScale` mixin. This mixin can be attached +to any Flame `Component` and exposes a simple get/set API for `timeScale`. The default value of +`timeScale` is `1`, implying in-game time of the component is running at the same speed as real life +time. Setting it to `2` will make the component tick twice as fast and setting it to `0.5` will make +it tick at half the speed as compared to real life time. + +Since `FlameGame` is a `Component` too, this mixin can be attached to the `FlameGame` as well. Doing +so will allow controlling time scale for all the component of the game from a single place. + +```{flutter-app} +:sources: ../flame/examples +:page: time_scale +:show: widget code infobox +:width: 180 +:height: 160 +``` + +```dart +import 'package:flame/components.dart'; +import 'package:flame/game.dart'; + +class MyFlameGame extends FlameGame with HasTimeScale { + void speedUp(){ + timeScale = 2.0; + } + + void slowDown(){ + timeScale = 1.0; + } +} +``` + + ## Extensions Flame bundles a collection of utility extensions, these extensions are meant to help the developer diff --git a/examples/lib/stories/components/components.dart b/examples/lib/stories/components/components.dart index eaf78d1ef..54d29f16b 100644 --- a/examples/lib/stories/components/components.dart +++ b/examples/lib/stories/components/components.dart @@ -9,6 +9,7 @@ import 'package:examples/stories/components/game_in_game_example.dart'; import 'package:examples/stories/components/look_at_example.dart'; import 'package:examples/stories/components/look_at_smooth_example.dart'; import 'package:examples/stories/components/priority_example.dart'; +import 'package:examples/stories/components/time_scale_example.dart'; import 'package:flame/game.dart'; void addComponentsStories(Dashbook dashbook) { @@ -67,5 +68,13 @@ void addComponentsStories(Dashbook dashbook) { codeLink: baseLink('components/components_notifier_provider_example.dart'), info: ComponentsNotifierProviderExampleWidget.description, + ) + ..add( + 'Time Scale', + (_) => const GameWidget.controlled( + gameFactory: TimeScaleExample.new, + ), + codeLink: baseLink('components/time_scale_example.dart'), + info: TimeScaleExample.description, ); } diff --git a/examples/lib/stories/components/time_scale_example.dart b/examples/lib/stories/components/time_scale_example.dart new file mode 100644 index 000000000..05f99063f --- /dev/null +++ b/examples/lib/stories/components/time_scale_example.dart @@ -0,0 +1,123 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:flame/collisions.dart'; +import 'package:flame/components.dart'; +import 'package:flame/game.dart'; +import 'package:flame/image_composition.dart'; +import 'package:flame/palette.dart'; +import 'package:flame/sprite.dart'; +import 'package:flutter/rendering.dart'; + +class TimeScaleExample extends FlameGame + with HasTimeScale, HasCollisionDetection { + static const description = + 'This example shows how time scale can be used to control game speed.'; + + final gameSpeedText = TextComponent( + text: 'Time Scale: 1', + textRenderer: TextPaint( + style: TextStyle( + color: BasicPalette.white.color, + fontSize: 20.0, + shadows: const [ + Shadow(offset: Offset(1, 1), blurRadius: 1), + ], + ), + ), + anchor: Anchor.center, + ); + + @override + Color backgroundColor() => const Color.fromARGB(255, 88, 114, 97); + + @override + Future onLoad() async { + camera.viewport = FixedResolutionViewport(Vector2(640, 360)); + final spriteSheet = SpriteSheet( + image: await images.load('animations/chopper.png'), + srcSize: Vector2.all(48), + ); + gameSpeedText.position = Vector2(size.x * 0.5, size.y * 0.8); + + await addAll([ + _Chopper( + position: Vector2(size.x * 0.3, size.y * 0.45), + size: Vector2.all(64), + anchor: Anchor.center, + angle: -pi / 2, + animation: spriteSheet.createAnimation(row: 0, stepTime: 0.05), + ), + _Chopper( + position: Vector2(size.x * 0.6, size.y * 0.55), + size: Vector2.all(64), + anchor: Anchor.center, + angle: pi / 2, + animation: spriteSheet.createAnimation(row: 0, stepTime: 0.05), + ), + gameSpeedText, + ]); + return super.onLoad(); + } + + @override + void update(double dt) { + gameSpeedText.text = 'Time Scale : $timeScale'; + super.update(dt); + } +} + +class _Chopper extends SpriteAnimationComponent + with HasGameRef, CollisionCallbacks { + _Chopper({ + super.animation, + super.position, + super.size, + super.angle, + super.anchor, + }) : _moveDirection = Vector2(0, 1)..rotate(angle ?? 0), + _initialPosition = position?.clone() ?? Vector2.zero(); + + final Vector2 _moveDirection; + final _speed = 80.0; + final Vector2 _initialPosition; + late final _timer = TimerComponent( + period: 2, + onTick: _reset, + autoStart: false, + ); + + @override + Future onLoad() async { + await add(CircleHitbox()); + await add(_timer); + return super.onLoad(); + } + + @override + void updateTree(double dt) { + position.setFrom(position + _moveDirection * _speed * dt); + super.updateTree(dt); + } + + @override + void onCollisionStart(Set _, PositionComponent other) { + if (other is _Chopper) { + gameRef.timeScale = 0.25; + } + super.onCollisionStart(_, other); + } + + @override + void onCollisionEnd(PositionComponent other) { + if (other is _Chopper) { + gameRef.timeScale = 1.0; + _timer.timer.start(); + } + super.onCollisionEnd(other); + } + + void _reset() { + position.setFrom(_initialPosition); + } +} diff --git a/packages/flame/lib/components.dart b/packages/flame/lib/components.dart index 09d58627d..3820f992b 100644 --- a/packages/flame/lib/components.dart +++ b/packages/flame/lib/components.dart @@ -20,6 +20,7 @@ export 'src/components/mixins/has_ancestor.dart'; export 'src/components/mixins/has_decorator.dart' show HasDecorator; export 'src/components/mixins/has_game_ref.dart' show HasGameRef; export 'src/components/mixins/has_paint.dart'; +export 'src/components/mixins/has_time_scale.dart'; export 'src/components/mixins/hoverable.dart'; export 'src/components/mixins/keyboard_handler.dart'; export 'src/components/mixins/notifier.dart'; diff --git a/packages/flame/lib/src/components/mixins/has_time_scale.dart b/packages/flame/lib/src/components/mixins/has_time_scale.dart new file mode 100644 index 000000000..55adee3e2 --- /dev/null +++ b/packages/flame/lib/src/components/mixins/has_time_scale.dart @@ -0,0 +1,37 @@ +import 'package:flame/src/components/core/component.dart'; + +/// This mixin allows components to control their speed as compared to the +/// normal speed. Only framerate independent logic will benefit [timeScale] +/// changes. +/// +/// Note: Modified [timeScale] will be applied to all children as well. +mixin HasTimeScale on Component { + /// The ratio of components tick speed and normal tick speed. + /// It defaults to 1.0, which means the component moves normally. + /// A value of 0.5 means the component moves half the normal speed + /// and a value of 2.0 means the component moves twice as fast. + double _timeScale = 1.0; + + /// Returns the current time scale. + double get timeScale => _timeScale; + + /// Sets the time scale to given value if it is non-negative. + /// Note: Too high values will result in inconsistent gameplay + /// and tunneling in physics. + set timeScale(double value) { + if (value.isNegative) { + return; + } + _timeScale = value; + } + + @override + void update(double dt) { + super.update(dt * (parent == null ? _timeScale : 1.0)); + } + + @override + void updateTree(double dt) { + super.updateTree(dt * (parent != null ? _timeScale : 1.0)); + } +} diff --git a/packages/flame/test/components/mixins/has_time_scale_test.dart b/packages/flame/test/components/mixins/has_time_scale_test.dart new file mode 100644 index 000000000..7290ead48 --- /dev/null +++ b/packages/flame/test/components/mixins/has_time_scale_test.dart @@ -0,0 +1,99 @@ +import 'package:flame/components.dart'; +import 'package:flame/game.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:test/test.dart'; + +void main() { + group('HasTimeScale', () { + testWithGame<_GameWithTimeScale>( + 'delta time scales correctly', + _GameWithTimeScale.new, + (game) async { + final component = _MovingComponent(); + await game.add(component); + await game.ready(); + const stepTime = 10.0; + var distance = 0.0; + final offset = stepTime * component.speed; + + game.timeScale = 0.5; + distance = component.x; + game.update(stepTime); + expect(component.x, distance + game.timeScale * offset); + + game.timeScale = 1.0; + distance = component.x; + game.update(stepTime); + expect(component.x, distance + game.timeScale * offset); + + game.timeScale = 1.5; + distance = component.x; + game.update(stepTime); + expect(component.x, distance + game.timeScale * offset); + + game.timeScale = 2.0; + distance = component.x; + game.update(stepTime); + expect(component.x, distance + game.timeScale * offset); + }, + ); + + testWithGame( + 'cascading time scale', + _GameWithTimeScale.new, + (game) async { + final component1 = _ComponentWithTimeScale(); + final component2 = _MovingComponent(); + await component1.add(component2); + await game.add(component1); + await game.ready(); + const stepTime = 10.0; + var distance = 0.0; + final offset = stepTime * component2.speed; + + game.timeScale = 0.5; + component1.timeScale = 0.5; + distance = component2.x; + game.update(stepTime); + expect( + component2.x, + distance + game.timeScale * component1.timeScale * offset, + ); + + game.timeScale = 1.0; + distance = component2.x; + game.update(stepTime); + expect( + component2.x, + distance + game.timeScale * component1.timeScale * offset, + ); + + component1.timeScale = 1.5; + distance = component2.x; + game.update(stepTime); + expect( + component2.x, + distance + game.timeScale * component1.timeScale * offset, + ); + + game.timeScale = 2.0; + distance = component2.x; + game.update(stepTime); + expect( + component2.x, + distance + game.timeScale * component1.timeScale * offset, + ); + }, + ); + }); +} + +class _GameWithTimeScale extends FlameGame with HasTimeScale {} + +class _ComponentWithTimeScale extends Component with HasTimeScale {} + +class _MovingComponent extends PositionComponent { + final speed = 1.0; + @override + void update(double dt) => position.setValues(position.x + speed * dt, 0); +}