Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Add
HasTimeScale
mixin (#2431)
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
1 parent
158460d
commit d2a8fe0
Showing
8 changed files
with
341 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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]; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
123 changes: 123 additions & 0 deletions
123
examples/lib/stories/components/time_scale_example.dart
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
37 changes: 37 additions & 0 deletions
37
packages/flame/lib/src/components/mixins/has_time_scale.dart
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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
99
packages/flame/test/components/mixins/has_time_scale_test.dart
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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); | ||
} |