feat(effects)!: Added SequenceEffect (#1218)

Added SequenceEffect, which performs a series of other effects.

The biggest challenge in implementing this feature came from the need to run the sequence in reverse, due to the alternate flag. This required that every effect and every controller supported running "back in time", which is not as simple as it sounds.

The following breaking changes were introduced:

    The Effect class no longer supports .reverse() method and .isReversed flag.

    This flag was added only 2 weeks ago (

Effect controllers restructuring #1134), with the idea that it will be necessary for the SequenceEffect. However, as it turned out, this flag is not as helpful as I thought it would be. In fact, given the user's ability to change it any point, it makes the implementation very error-prone.

To be clear, the ability for each effect to run in reverse remains -- only now it can no longer be triggered by the user manually. Instead, SequenceEffect triggers that ability itself at the alternation point. If there is demand in the future to manually force any effect to run backwards, we could restore this flag, but this would require thorough testing to make it work correctly.

Infinite effects now return duration = double.infinity instead of null, which seems more appropriate.
This commit is contained in:
Pasha Stetsenko
2021-12-27 12:57:48 -08:00
committed by GitHub
parent 20f521f5be
commit 7c6ae6def3
17 changed files with 688 additions and 52 deletions

View File

@ -42,6 +42,7 @@ There are multiple effects provided by Flame, and you can also
- [`SizeEffect.to`](#sizeeffectto)
- [`OpacityEffect`](#opacityeffect)
- [`ColorEffect`](#coloreffect)
- [`SequenceEffect`](#sequenceeffect)
- [`RemoveEffect`](#removeeffect)
An `EffectController` is an object that describes how the effect should evolve over time. If you
@ -146,7 +147,7 @@ is in radians. For example, the following effect will rotate the target 90º (=[
clockwise:
```dart
final effect = RotateEffect.by(tau/4, EffectController(2));
final effect = RotateEffect.by(tau/4, EffectController(duration: 2));
```
@ -156,7 +157,7 @@ Rotates the target clockwise to the specified angle. For example, the following
target to look east (0º is north, 90º=[tau]/4 east, 180º=tau/2 south, and 270º=tau*3/4 west):
```dart
final effect = RotateEffect.to(tau/4, EffectController(2));
final effect = RotateEffect.to(tau/4, EffectController(duration: 2));
```
@ -166,7 +167,7 @@ This effect will change the target's scale by the specified amount. For example,
the component to grow 50% larger:
```dart
final effect = ScaleEffect.by(Vector2.all(1.5), EffectController(0.3));
final effect = ScaleEffect.by(Vector2.all(1.5), EffectController(duration: 0.3));
```
@ -175,7 +176,7 @@ final effect = ScaleEffect.by(Vector2.all(1.5), EffectController(0.3));
This effect works similar to `ScaleEffect.by`, but sets the absolute value of the target's scale.
```dart
final effect = ScaleEffect.to(Vector2.zero(), EffectController(0.5));
final effect = ScaleEffect.to(Vector2.zero(), EffectController(duration: 0.5));
```
@ -186,7 +187,7 @@ if the target has size `Vector2(100, 100)`, then after the following effect is a
course, the new size will be `Vector2(120, 50)`:
```dart
final effect = SizeEffect.by(Vector2(20, -50), EffectController(1));
final effect = SizeEffect.by(Vector2(20, -50), EffectController(duration: 1));
```
The size of a `PositionComponent` cannot be negative. If an effect attempts to set the size to a
@ -203,7 +204,7 @@ more generally and scales the children components too.
Changes the size of the target component to the specified size. Target size cannot be negative:
```dart
final effect = SizeEffect.to(Vector2(120, 120), EffectController(1));
final effect = SizeEffect.to(Vector2(120, 120), EffectController(duration: 1));
```
@ -214,7 +215,7 @@ this effect can only be applied to components that have a `HasPaint` mixin. If t
uses multiple paints, the effect can target any individual color using the `paintId` parameter.
```dart
final effect = OpacityEffect.to(0.5, EffectController(0.75));
final effect = OpacityEffect.to(0.5, EffectController(duration: 0.75));
```
The opacity value of 0 corresponds to a fully transparent component, and the opacity value of 1 is
@ -222,6 +223,24 @@ fully opaque. Convenience constructors `OpacityEffect.fadeOut()` and `OpacityEff
animate the target into full transparency / full visibility respectively.
### `SequenceEffect`
This effect can be used to run multiple other effects one after another. The constituent effects
may have different types.
The sequence effect can also be alternating (the sequence will first run forward, and then
backward); and also repeat a certain predetermined number of times, or infinitely.
```dart
final effect = SequenceEffect([
ScaleEffect.by(1.5, EffectController(duration: 0.2, alternate: true)),
MoveEffect.by(Vector2(30, -50), EffectController(duration: 0.5)),
OpacityEffect.to(0, EffectController(duration: 0.3)),
RemoveEffect(),
]);
```
### `RemoveEffect`
This is a simple effect that can be attached to a component causing it to be removed from the game

View File

@ -9,6 +9,7 @@ import 'opacity_effect_example.dart';
import 'remove_effect_example.dart';
import 'rotate_effect_example.dart';
import 'scale_effect_example.dart';
import 'sequence_effect_example.dart';
import 'size_effect_example.dart';
void addEffectsStories(Dashbook dashbook) {
@ -49,6 +50,12 @@ void addEffectsStories(Dashbook dashbook) {
codeLink: baseLink('effects/color_effect_example.dart'),
info: ColorEffectExample.description,
)
..add(
'Sequence Effect',
(_) => GameWidget(game: SequenceEffectExample()),
codeLink: baseLink('effects/sequence_effect_example.dart'),
info: SequenceEffectExample.description,
)
..add(
'Remove Effect',
(_) => GameWidget(game: RemoveEffectExample()),

View File

@ -0,0 +1,63 @@
import 'dart:ui';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flame/game.dart';
class SequenceEffectExample extends FlameGame {
static const String description = '''
Sequence of effects, consisting of a move effect, a rotate effect, another
move effect, a scale effect, and then one more move effect. The sequence
then runs in the opposite order (alternate = true) and loops infinitely
(infinite = true).
''';
@override
Future<void> onLoad() async {
super.onLoad();
const tau = Transform2D.tau;
EffectController duration(double x) => EffectController(duration: x);
add(
Player()
..position = Vector2(200, 300)
..add(
SequenceEffect(
[
MoveEffect.to(Vector2(400, 300), duration(0.7)),
RotateEffect.by(tau / 4, duration(0.5)),
MoveEffect.to(Vector2(400, 400), duration(0.7)),
ScaleEffect.by(Vector2.all(1.5), duration(0.7)),
MoveEffect.to(Vector2(400, 500), duration(0.7)),
],
alternate: true,
infinite: true,
),
),
);
}
}
class Player extends PositionComponent {
Player()
: path = Path()
..lineTo(40, 20)
..lineTo(0, 40)
..quadraticBezierTo(8, 20, 0, 0)
..close(),
bodyPaint = Paint()..color = const Color(0x887F99B3),
borderPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 3
..color = const Color(0xFFFFFD9A),
super(anchor: Anchor.center, size: Vector2(40, 40));
final Path path;
final Paint borderPaint;
final Paint bodyPaint;
@override
void render(Canvas canvas) {
canvas.drawPath(path, bodyPaint);
canvas.drawPath(path, borderPaint);
}
}

View File

@ -1 +1 @@
62.0
67.0

View File

@ -21,5 +21,6 @@ export 'src/effects/opacity_effect.dart';
export 'src/effects/remove_effect.dart';
export 'src/effects/rotate_effect.dart';
export 'src/effects/scale_effect.dart';
export 'src/effects/sequence_effect.dart' show SequenceEffect;
export 'src/effects/size_effect.dart';
export 'src/effects/transform2d_effect.dart';

View File

@ -22,8 +22,12 @@ abstract class ComponentEffect<T extends Component> extends Effect {
void onMount() {
super.onMount();
assert(parent != null);
if (parent is T) {
target = parent! as T;
var p = parent;
while (p is Effect) {
p = p.parent;
}
if (p is T) {
target = p;
} else {
throw UnsupportedError('Can only apply this effect to $T');
}
@ -41,4 +45,10 @@ abstract class ComponentEffect<T extends Component> extends Effect {
super.reset();
_lastProgress = 0;
}
@override
void resetToEnd() {
super.resetToEnd();
_lastProgress = 1;
}
}

View File

@ -14,9 +14,6 @@ class DelayedEffectController extends EffectController {
final double delay;
double _timer;
@override
bool get isInfinite => _child.isInfinite;
@override
bool get isRandom => _child.isRandom;

View File

@ -217,13 +217,13 @@ abstract class EffectController {
EffectController.empty();
/// Will the effect continue to run forever (never completes)?
bool get isInfinite => false;
bool get isInfinite => duration == double.infinity;
/// Is the effect's duration random or fixed?
bool get isRandom => false;
/// Total duration of the effect. If the duration cannot be determined, this
/// will return `null`.
/// will return `null`. For an infinite effect the duration is infinity.
double? get duration;
/// Has the effect started running? Some effects use a "delay" parameter to
@ -247,7 +247,8 @@ abstract class EffectController {
/// If the controller is still running, the return value will be 0. If it
/// already finished, then the return value will be the "leftover" part of
/// the [dt]. That is, the amount of time [dt] that remains after the
/// controller has finished.
/// controller has finished. In all cases, the return value can be positive
/// only when `completed == true`.
///
/// Normally, this method will be called by the owner of the controller class.
/// For example, if the controller is passed to an [Effect] class, then that

View File

@ -8,18 +8,18 @@ class InfiniteEffectController extends EffectController {
final EffectController child;
@override
bool get isInfinite => true;
@override
bool get completed => false;
@override
double? get duration => null;
double? get duration => double.infinity;
@override
double get progress => child.progress;
@override
bool get isRandom => child.isRandom;
@override
double advance(double dt) {
var t = dt;

View File

@ -55,9 +55,6 @@ class RandomEffectController extends EffectController {
final DurationEffectController child;
final RandomVariable randomGenerator;
@override
bool get isInfinite => false;
@override
bool get isRandom => true;

View File

@ -33,10 +33,14 @@ class RepeatedEffectController extends EffectController {
return d == null ? null : d * repeatCount;
}
@override
bool get isRandom => child.isRandom;
@override
double advance(double dt) {
var t = child.advance(dt);
while (t > 0 && _remainingCount > 0) {
assert(child.completed);
_remainingCount--;
if (_remainingCount != 0) {
child.setToStart();
@ -56,6 +60,7 @@ class RepeatedEffectController extends EffectController {
// if we recede from the end position the remaining count must be
// adjusted.
_remainingCount = 1;
assert(child.completed);
}
var t = child.recede(dt);
while (t > 0 && _remainingCount < repeatCount) {

View File

@ -30,8 +30,7 @@ abstract class Effect extends Component {
: removeOnFinish = true,
_paused = false,
_started = false,
_finished = false,
_reversed = false {
_finished = false {
controller.onMount(this);
}
@ -63,13 +62,6 @@ abstract class Effect extends Component {
bool get isPaused => _paused;
bool _paused;
/// Whether the effect is currently running back in time.
///
/// Call `reverse()` in order to change this. When the effect is reset, this
/// is set to false.
bool get isReversed => _reversed;
bool _reversed;
/// Pause the effect. The effect will not respond to updates while it is
/// paused. Calling `resume()` or `reset()` will un-pause it. Pausing an
/// already paused effect is a no-op.
@ -79,9 +71,6 @@ abstract class Effect extends Component {
/// currently paused, this call is a no-op.
void resume() => _paused = false;
/// Cause the effect to run back in time.
void reverse() => _reversed = !_reversed;
/// Restore the effect to its original state as it was when the effect was
/// just created.
///
@ -95,7 +84,13 @@ abstract class Effect extends Component {
_paused = false;
_started = false;
_finished = false;
_reversed = false;
}
@mustCallSuper
void resetToEnd() {
controller.setToEnd();
_started = true;
_finished = true;
}
/// Implementation of [Component]'s `update()` method. Derived classes are
@ -109,11 +104,7 @@ abstract class Effect extends Component {
_started = true;
onStart();
}
if (_reversed) {
controller.recede(dt);
} else {
controller.advance(dt);
}
controller.advance(dt);
if (_started) {
apply(controller.progress);
}
@ -126,6 +117,42 @@ abstract class Effect extends Component {
}
}
/// Used for SequenceEffect. This is similar to `update()`, but cannot be
/// paused, does not obey [removeOnFinish], and returns the "leftover time"
/// similar to `EffectController.advance`.
@internal
double advance(double dt) {
final remainingDt = controller.advance(dt);
if (!_started && controller.started) {
_started = true;
onStart();
}
if (_started) {
apply(controller.progress);
}
if (!_finished && controller.completed) {
_finished = true;
onFinish();
}
return remainingDt;
}
/// Used for SequenceEffect. This is similar to `update()`, but the effect is
/// moved back in time. The callbacks onStart/onFinish will not be called.
/// This method returns the "leftover time" similar to
/// `EffectController.recede`.
@internal
double recede(double dt) {
if (_finished && dt > 0) {
_finished = false;
}
final remainingDt = controller.recede(dt);
if (_started) {
apply(controller.progress);
}
return remainingDt;
}
//#region API to be implemented by the derived classes
/// This method is called once when the effect is about to start, but before

View File

@ -0,0 +1,207 @@
import 'controllers/effect_controller.dart';
import 'controllers/infinite_effect_controller.dart';
import 'controllers/repeated_effect_controller.dart';
import 'effect.dart';
/// Run multiple effects in a sequence, one after another.
///
/// The provided effects will be added as child components; however the custom
/// `updateTree()` implementation ensures that only one of them runs at any
/// point in time. The flags `paused` or `removeOnFinish` will be ignored for
/// children effects.
///
/// If the `alternate` flag is provided, then the sequence will run in the
/// reverse after it ran forward.
///
/// Parameter `repeatCount` will make the sequence repeat a certain number of
/// times. If `alternate` is also true, then the sequence will first run
/// forward, then back, and then repeat this pattern `repeatCount` times in
/// total.
///
/// The flag `infinite = true` makes the sequence repeat infinitely. This is
/// equivalent to setting `repeatCount = infinity`. If both the `infinite` and
/// the `repeatCount` parameters are given, then `infinite` takes precedence.
///
/// Note that unlike other effects, [SequenceEffect] does not take an
/// [EffectController] as a parameter. This is because the timing of a sequence
/// effect depends on the timings of individual effects, and cannot be
/// represented as a regular effect controller.
class SequenceEffect extends Effect {
factory SequenceEffect(
List<Effect> effects, {
bool alternate = false,
bool infinite = false,
int repeatCount = 1,
}) {
assert(effects.isNotEmpty, 'The list of effects cannot be empty');
assert(
!(infinite && repeatCount != 1),
'Parameters infinite and repeatCount cannot be specified simultaneously',
);
EffectController ec = _SequenceEffectEffectController(effects, alternate);
if (infinite) {
ec = InfiniteEffectController(ec);
} else if (repeatCount > 1) {
ec = RepeatedEffectController(ec, repeatCount);
}
effects.forEach((e) => e.removeOnFinish = false);
return SequenceEffect._(ec)..addAll(effects);
}
SequenceEffect._(EffectController ec) : super(ec);
@override
void apply(double progress) {}
@override
void updateTree(double dt) {
update(dt);
// Do not update children: the controller will take care of it
}
}
/// Helper class that implements the functionality of a [SequenceEffect]. This
/// class should not be confused with `SequenceEffectController` (which runs
/// a sequence of effect controllers).
///
/// This effect controller does not strictly adheres to the interface of a
/// proper [EffectController]: in particular, its [progress] is ill-defined.
/// The provided implementation returns a value proportional to the number of
/// effects that has already completed, however this is not used anywhere since
/// `SequenceEffect.apply()` is empty.
class _SequenceEffectEffectController extends EffectController {
_SequenceEffectEffectController(
this.effects,
this.alternate,
) : super.empty();
/// The list of children effects.
final List<Effect> effects;
/// If this flag is true, then after the sequence runs to the end, it will
/// run again in the reverse order.
final bool alternate;
/// Index of the currently running effect within the [effects] list. If there
/// are n effects in total, then this runs as 0, 1, ..., n-1. After that, if
/// the effect alternates, then the `_index` continues as -1, -2, ..., -n,
/// where -1 is the last effect and -n is the first.
int _index = 0;
/// The effect that is currently being executed.
Effect get currentEffect => effects[_index < 0 ? _index + n : _index];
/// Total number of effects in this sequence.
int get n => effects.length;
@override
bool get completed => _completed;
bool _completed = false;
@override
double? get duration {
var totalDuration = 0.0;
for (final effect in effects) {
totalDuration += effect.controller.duration ?? 0;
}
if (alternate) {
totalDuration *= 2;
}
return totalDuration;
}
@override
bool get isRandom {
return effects.any((e) => e.controller.isRandom);
}
@override
double get progress => (_index + 1) / n;
@override
double advance(double dt) {
var t = dt;
for (;;) {
if (_index >= 0) {
t = currentEffect.advance(t);
if (t > 0) {
_index += 1;
if (_index == n) {
if (alternate) {
_index = -1;
} else {
_index = n - 1;
_completed = true;
break;
}
}
}
} else {
t = currentEffect.recede(t);
if (t > 0) {
_index -= 1;
if (_index < -n) {
_index = -n;
_completed = true;
break;
}
}
}
if (t == 0) {
break;
}
}
return t;
}
@override
double recede(double dt) {
if (_completed && dt > 0) {
_completed = false;
}
var t = dt;
for (;;) {
if (_index >= 0) {
t = currentEffect.recede(t);
if (t > 0) {
_index -= 1;
if (_index < 0) {
_index = 0;
break;
}
}
} else {
t = currentEffect.advance(t);
if (t > 0) {
_index += 1;
if (_index == 0) {
_index = n - 1;
}
}
}
if (t == 0) {
break;
}
}
return t;
}
@override
void setToEnd() {
if (alternate) {
_index = -n;
effects.forEach((e) => e.reset());
} else {
_index = n - 1;
effects.forEach((e) => e.resetToEnd());
}
_completed = true;
}
@override
void setToStart() {
_index = 0;
_completed = false;
effects.forEach((e) => e.reset());
}
}

View File

@ -74,16 +74,9 @@ class FlameGame extends Component with Game {
_cameraWrapper.render(canvas);
}
/// This updates every component in the tree.
///
/// It also adds the components added via [add] since the previous tick, and
/// removes those that are marked for removal via the [remove] and
/// [Component.removeFromParent] methods.
/// You can override it to add more custom behavior.
@override
@mustCallSuper
void update(double dt) {
super.update(dt);
_cameraWrapper.update(dt);
if (parent == null) {
updateTree(dt);

View File

@ -11,7 +11,7 @@ void main() {
final ec = InfiniteEffectController(LinearEffectController(1));
expect(ec.isInfinite, true);
expect(ec.isRandom, false);
expect(ec.duration, null);
expect(ec.duration, double.infinity);
expect(ec.started, true);
expect(ec.completed, false);
expect(ec.progress, 0);

View File

@ -0,0 +1,309 @@
import 'dart:math';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flame/game.dart';
import 'package:flame_test/flame_test.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('SequenceEffect', () {
group('properties', () {
test('simple', () {
final effect = SequenceEffect([
MoveEffect.to(Vector2(10, 10), EffectController(duration: 3)),
MoveEffect.by(
Vector2(1, 0),
EffectController(duration: 0.1, repeatCount: 15),
),
]);
expect(effect.controller.duration, 4.5);
expect(effect.controller.isRandom, false);
expect(effect.controller.completed, false);
});
test('alternating', () {
final effect = SequenceEffect(
[
MoveEffect.to(Vector2(10, 10), EffectController(duration: 3)),
],
alternate: true,
);
expect(effect.controller.duration, 6);
expect(effect.controller.isRandom, false);
});
test('infinite', () {
final effect = SequenceEffect(
[
MoveEffect.to(Vector2.zero(), EffectController(duration: 1)),
],
alternate: true,
infinite: true,
);
expect(effect.controller.duration, double.infinity);
expect(effect.controller.isRandom, false);
});
test('with random effects', () {
final randomEffect = MoveEffect.to(
Vector2(10, 10),
RandomEffectController.uniform(
LinearEffectController(0),
min: 1,
max: 5,
),
);
final effect = SequenceEffect(
[randomEffect],
alternate: true,
repeatCount: 1000,
);
expect(
effect.controller.duration,
closeTo(randomEffect.controller.duration! * 2000, 1e-15),
);
expect(effect.controller.isRandom, true);
});
test('errors', () {
expect(
() => SequenceEffect(<Effect>[]),
failsAssert('The list of effects cannot be empty'),
);
expect(
() => SequenceEffect(
[MoveEffect.to(Vector2.zero(), EffectController(duration: 1))],
infinite: true,
repeatCount: 10,
),
failsAssert(
'Parameters infinite and repeatCount cannot be specified '
'simultaneously',
),
);
});
});
group('sequence progression', () {
test('simple sequence', () async {
final effect = SequenceEffect([
MoveEffect.by(Vector2(10, 0), EffectController(duration: 1)),
MoveEffect.by(Vector2(0, 10), EffectController(duration: 2)),
MoveEffect.by(Vector2(-10, 0), EffectController(duration: 3)),
MoveEffect.by(Vector2(30, 30), EffectController(duration: 4)),
]);
final component = PositionComponent()..add(effect);
final game = FlameGame()..onGameResize(Vector2.all(1000));
await game.ensureAdd(component);
game.update(0);
// Each point here is spaced 0.1 seconds apart
final expectedPositions = <Vector2>[
...List.generate(10, (i) => Vector2(i * 1.0, 0)),
...List.generate(20, (i) => Vector2(10, i * 0.5)),
...List.generate(30, (i) => Vector2(10 - i / 3, 10)),
...List.generate(40, (i) => Vector2(i * 0.75, 10 + i * 0.75)),
Vector2(30, 40),
];
for (final p in expectedPositions) {
expect(component.position, closeToVector(p.x, p.y, epsilon: 1e-12));
game.update(0.1);
}
});
test('large step', () async {
final effect = SequenceEffect([
MoveEffect.by(Vector2(10, 0), EffectController(duration: 1)),
MoveEffect.by(Vector2(0, 10), EffectController(duration: 2)),
MoveEffect.by(Vector2(-10, 0), EffectController(duration: 3)),
MoveEffect.by(Vector2(30, 30), EffectController(duration: 4)),
]);
final component = PositionComponent()..add(effect);
final game = FlameGame()..onGameResize(Vector2.all(1000));
await game.ensureAdd(component);
game.update(10);
expect(component.position, closeToVector(30, 40));
});
test('k-step sequence', () async {
final effect = SequenceEffect(
[
MoveEffect.by(Vector2(10, 0), EffectController(duration: 1)),
MoveEffect.by(Vector2(0, 10), EffectController(duration: 1)),
],
repeatCount: 5,
);
final component = PositionComponent()..add(effect);
final game = FlameGame()..onGameResize(Vector2.all(1000));
await game.ensureAdd(component);
for (var i = 0; i < 10; i++) {
final x = ((i + 1) ~/ 2) * 10;
final y = (i ~/ 2) * 10;
expect(component.position, closeToVector(x, y));
expect(effect.isMounted, true);
game.update(1);
}
game.update(5); // Will schedule the `effect` component for deletion
game.update(0); // Second update ensures the game deletes the component
expect(effect.isMounted, false);
expect(component.position, closeToVector(50, 50));
});
test('alternating sequence', () async {
final effect = SequenceEffect(
[
MoveEffect.by(Vector2(10, 0), EffectController(duration: 1)),
MoveEffect.by(Vector2(0, 10), EffectController(duration: 1)),
],
alternate: true,
);
expect(effect.controller.duration, 4);
final component = PositionComponent()..add(effect);
final game = FlameGame()..onGameResize(Vector2.all(1000));
await game.ensureAdd(component);
game.update(0);
final expectedPath = <Vector2>[
for (var i = 0.0; i < 10; i++) Vector2(i, 0),
for (var i = 0.0; i < 10; i++) Vector2(10, i),
for (var i = 10.0; i > 0; i--) Vector2(10, i),
for (var i = 10.0; i > 0; i--) Vector2(i, 0),
];
for (final p in expectedPath) {
expect(component.position, closeToVector(p.x, p.y, epsilon: 1e-14));
game.update(0.1);
}
game.update(0.001);
expect(effect.controller.completed, true);
});
test('sequence of alternates', () async {
EffectController controller() =>
EffectController(duration: 1, alternate: true);
final effect = SequenceEffect(
[
MoveEffect.by(Vector2(1, 0), controller()),
MoveEffect.by(Vector2(0, 1), controller()),
],
alternate: true,
);
final component = PositionComponent()..add(effect);
final game = FlameGame()..onGameResize(Vector2.all(1000));
await game.ensureAdd(component);
game.update(0);
final forwardPath = <Vector2>[
for (var i = 0; i < 10; i++) Vector2(i * 0.1, 0),
for (var i = 10; i > 0; i--) Vector2(i * 0.1, 0),
for (var i = 0; i < 10; i++) Vector2(0, i * 0.1),
for (var i = 10; i > 0; i--) Vector2(0, i * 0.1),
];
final expectedPath = [
...forwardPath,
Vector2.zero(),
...forwardPath.reversed,
];
for (final p in expectedPath) {
expect(component.position, closeToVector(p.x, p.y, epsilon: 1e-14));
game.update(0.1);
}
game.update(0.001);
expect(component.position, closeToVector(0, 0));
expect(effect.controller.completed, true);
});
test('sequence in sequence', () async {
EffectController duration(double t) => EffectController(duration: t);
const dt = 0.01;
const x0 = 0.0, y0 = 0.0;
const x1 = 10.0, y1 = 10.0;
const x2 = 20.0, y2 = 0.0;
const x3 = 30.0, y3 = 10.0;
const x4 = 10.0, y4 = 30.0;
const dx5 = 1.6, dy5 = 0.9;
final effect = SequenceEffect(
[
MoveEffect.by(Vector2(x1 - x0, y1 - y0), duration(1)),
SequenceEffect(
[
MoveEffect.to(Vector2(x2, y2), duration(1)),
MoveEffect.to(Vector2(x3, y3), duration(1)),
],
alternate: true,
repeatCount: 2,
),
MoveEffect.by(Vector2(x4 - x1, y4 - y1), duration(2)),
SequenceEffect(
[
MoveEffect.by(Vector2(dx5, 0), duration(1)),
MoveEffect.by(Vector2(0, dy5), duration(1)),
],
repeatCount: 5,
),
],
alternate: true,
);
expect(effect.controller.duration, 42);
final component = PositionComponent()..add(effect);
final game = FlameGame()..onGameResize(Vector2.all(1000));
await game.ensureAdd(component);
game.update(0);
// All points here are spaced `dt = 0.01` apart
final forwardPath = <Vector2>[
// First MoveEffect
for (var t = 0.0; t < 1; t += dt)
Vector2(x0 + (x1 - x0) * t, y0 + (y1 - y0) * t),
// First SequenceEffect
for (var t = 0.0; t < 1; t += dt)
Vector2(x1 + (x2 - x1) * t, y1 + (y2 - y1) * t),
for (var t = 0.0; t < 1; t += dt)
Vector2(x2 + (x3 - x2) * t, y2 + (y3 - y2) * t),
for (var t = 1.0; t > 0; t -= dt)
Vector2(x2 + (x3 - x2) * t, y2 + (y3 - y2) * t),
for (var t = 1.0; t > 0; t -= dt)
Vector2(x1 + (x2 - x1) * t, y1 + (y2 - y1) * t),
// First SequenceEffect, repeated second time
for (var t = 0.0; t < 1; t += dt)
Vector2(x1 + (x2 - x1) * t, y1 + (y2 - y1) * t),
for (var t = 0.0; t < 1; t += dt)
Vector2(x2 + (x3 - x2) * t, y2 + (y3 - y2) * t),
for (var t = 1.0; t > 0; t -= dt)
Vector2(x2 + (x3 - x2) * t, y2 + (y3 - y2) * t),
for (var t = 1.0; t > 0; t -= dt)
Vector2(x1 + (x2 - x1) * t, y1 + (y2 - y1) * t),
// Second MoveEffect, duration = 2
for (var t = 0.0; t < 2; t += dt)
Vector2(x1 + (x4 - x1) * t, y1 + (y4 - y1) * t / 2),
// Second sequence effect, repeated 5 times
for (var j = 0; j < 5; j++)
for (var t = 0.0; t < 2; t += dt)
Vector2(
x4 + min(j + t, j + 1) * dx5,
y4 + max(j + t - 1, j) * dy5,
),
];
final expectedPath = <Vector2>[
...forwardPath,
Vector2(x4 + 5 * dx5, y4 + 5 * dy5),
...forwardPath.reversed,
];
for (final p in expectedPath) {
expect(component.position, closeToVector(p.x, p.y, epsilon: 1e-12));
game.update(dt);
}
game.update(1e-5);
expect(effect.controller.completed, true);
});
});
});
}

View File

@ -1,15 +1,15 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:vector_math/vector_math_64.dart';
/// Returns a matcher which matches if the argument vector is within distance
/// Returns a matcher which checks if the argument is a vector within distance
/// [epsilon] of point ([x], [y]). For example:
///
/// ```dart
/// expect(scale, closeToVector(2, -2));
/// expect(position, closeToVector(120, 150, epsilon: 1e-10));
/// ```
Matcher closeToVector(double x, double y, {double epsilon = 1e-15}) {
return _IsCloseTo(Vector2(x, y), epsilon);
Matcher closeToVector(num x, num y, {double epsilon = 1e-15}) {
return _IsCloseTo(Vector2(x.toDouble(), y.toDouble()), epsilon);
}
class _IsCloseTo extends Matcher {