Skip to content

Commit

Permalink
feat: Add HasTimeScale mixin (#2431)
Browse files Browse the repository at this point in the history
This PR adds a new mixin on Component. When attached to a component, it allows scaling the delta time of that component as well as all its children by a non-negative factor. The idea is to allows slowing down or speeding up the gameplay by change the scaling factor.

Note: This approach works only for framerate independent game logic. Code in update() that is not dependent on delta time will remain unaffected by time scale.
  • Loading branch information
ufrshubham committed Mar 27, 2023
1 parent 158460d commit d2a8fe0
Show file tree
Hide file tree
Showing 8 changed files with 341 additions and 0 deletions.
2 changes: 2 additions & 0 deletions doc/flame/examples/lib/main.dart
Expand Up @@ -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';
Expand Down Expand Up @@ -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) {
Expand Down
31 changes: 31 additions & 0 deletions 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<void> 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];
}
}
39 changes: 39 additions & 0 deletions doc/flame/other/util.md
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions examples/lib/stories/components/components.dart
Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
);
}
123 changes: 123 additions & 0 deletions 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<void> 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<TimeScaleExample>, 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<void> 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<Vector2> _, 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);
}
}
1 change: 1 addition & 0 deletions packages/flame/lib/components.dart
Expand Up @@ -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';
Expand Down
37 changes: 37 additions & 0 deletions 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));
}
}
99 changes: 99 additions & 0 deletions 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);
}

0 comments on commit d2a8fe0

Please sign in to comment.