MoveAlongPathEffect (#1172)

* MoveAlongPathEffect separated from MoveEffect

* rename _lastPosition -> _lastOffset

* Added absolute= flag in MoveAlongPathEffect

* added test for absolute path

* add oriented= flag

* added test

* changelog

Co-authored-by: Lukas Klingsbo <lukas.klingsbo@gmail.com>
This commit is contained in:
Pasha Stetsenko
2021-12-07 11:45:53 -08:00
committed by GitHub
parent 3ae134aee5
commit 96991122f4
7 changed files with 232 additions and 104 deletions

View File

@ -89,14 +89,16 @@ class MoveEffectExample extends FlameGame {
for (var i = 0; i < 40; i++) { for (var i = 0; i < 40; i++) {
add( add(
CircleComponent(radius: 5) CircleComponent(radius: 5)
..position = Vector2(0, -1000)
..add( ..add(
MoveEffect.along( MoveAlongPathEffect(
path1, path1,
EffectController( EffectController(
duration: 10, duration: 10,
startDelay: i * 0.2, startDelay: i * 0.2,
infinite: true, infinite: true,
), ),
absolute: true,
), ),
), ),
); );
@ -108,13 +110,14 @@ class MoveEffectExample extends FlameGame {
RectangleComponent.square(size: 10) RectangleComponent.square(size: 10)
..paint = (Paint()..color = Colors.tealAccent) ..paint = (Paint()..color = Colors.tealAccent)
..add( ..add(
MoveEffect.along( MoveAlongPathEffect(
path2, path2,
EffectController( EffectController(
duration: 6, duration: 6,
startDelay: i * 0.3, startDelay: i * 0.3,
infinite: true, infinite: true,
), ),
oriented: true,
), ),
), ),
); );

View File

@ -11,6 +11,7 @@
- Fix render order of components and add tests - Fix render order of components and add tests
- `isHud` renamed to `respectCamera` - `isHud` renamed to `respectCamera`
- Fix `HitboxCircle` when component is flipped - Fix `HitboxCircle` when component is flipped
- `MoveAlongPathEffect` can now be absolute, and can auto-orient the object along the path
- `ScaleEffect.by` now applies multiplicatively instead of additively - `ScaleEffect.by` now applies multiplicatively instead of additively
## [1.0.0-releasecandidate.18] ## [1.0.0-releasecandidate.18]

View File

@ -11,6 +11,7 @@ export 'src/effects/controllers/reverse_curved_effect_controller.dart';
export 'src/effects/controllers/reverse_linear_effect_controller.dart'; export 'src/effects/controllers/reverse_linear_effect_controller.dart';
export 'src/effects/controllers/sequence_effect_controller.dart'; export 'src/effects/controllers/sequence_effect_controller.dart';
export 'src/effects/effect.dart'; export 'src/effects/effect.dart';
export 'src/effects/move_along_path_effect.dart';
export 'src/effects/move_effect.dart'; export 'src/effects/move_effect.dart';
export 'src/effects/opacity_effect.dart'; export 'src/effects/opacity_effect.dart';
export 'src/effects/remove_effect.dart'; export 'src/effects/remove_effect.dart';

View File

@ -0,0 +1,98 @@
import 'dart:ui';
import 'package:vector_math/vector_math_64.dart';
import 'controllers/effect_controller.dart';
import 'transform2d_effect.dart';
/// This effect will move the target along the specified path, which may
/// contain curved segments, but must be simply-connected.
///
/// If `absolute` is false (default), the `path` argument will be taken as
/// relative to the target's position at the start of the effect. It is
/// recommended in this case to have a path that starts at the origin in order
/// to avoid sudden jumps in the target's position.
///
/// If `absolute` flag is true, then the `path` will be assumed to be given in
/// absolute coordinate space and the target will be placed at the beginning of
/// the path when the effect starts.
///
/// The `oriented` flag controls the direction of the target as it follows the
/// path. If this flag is false (default), the target keeps its original
/// orientation. If the flag is true, the target is automatically rotated as it
/// follows the path so that it is always oriented tangent to the path.
class MoveAlongPathEffect extends Transform2DEffect {
MoveAlongPathEffect(
Path path,
EffectController controller, {
bool absolute = false,
bool oriented = false,
}) : _isAbsolute = absolute,
_followDirection = oriented,
super(controller) {
final metrics = path.computeMetrics().toList();
if (metrics.length != 1) {
throw ArgumentError(
'Only single-contour paths are allowed in MoveAlongPathEffect',
);
}
_pathMetric = metrics[0];
_pathLength = _pathMetric.length;
assert(_pathLength > 0);
}
/// If true, the path is considered _absolute_, i.e. the component will be
/// put onto the start of the path and then follow that path. If false, the
/// path is considered _relative_, i.e. this path is added as an offset to
/// the current position of the target.
final bool _isAbsolute;
/// If true, then not only the target's position will follow the path, but
/// also the target's angle of rotation.
final bool _followDirection;
/// The path that the target will follow.
late final PathMetric _pathMetric;
/// Pre-computed length of the path.
late final double _pathLength;
/// Position offset that was applied to the target on the previous iteration.
/// This is needed in order to make updates to `target.position` incremental
/// (which in turn is necessary in order to allow multiple effects to be able
/// to apply to the same target simultaneously).
late Vector2 _lastOffset;
/// Target's angle of rotation on the previous iteration.
late double _lastAngle;
@override
void onStart() {
_lastOffset = Vector2.zero();
_lastAngle = 0;
if (_isAbsolute) {
final start = _pathMetric.getTangentForOffset(0)!;
target.position.x = _lastOffset.x = start.position.dx;
target.position.y = _lastOffset.y = start.position.dy;
if (_followDirection) {
target.angle = _lastAngle = -start.angle;
}
}
}
@override
void apply(double progress) {
final distance = progress * _pathLength;
final tangent = _pathMetric.getTangentForOffset(distance)!;
final offset = tangent.position;
target.position.x += offset.dx - _lastOffset.x;
target.position.y += offset.dy - _lastOffset.y;
_lastOffset.x = offset.dx;
_lastOffset.y = offset.dy;
if (_followDirection) {
target.angle += -tangent.angle - _lastAngle;
_lastAngle = -tangent.angle;
}
super.apply(progress);
}
}

View File

@ -1,5 +1,3 @@
import 'dart:ui';
import 'package:vector_math/vector_math_64.dart'; import 'package:vector_math/vector_math_64.dart';
import 'controllers/effect_controller.dart'; import 'controllers/effect_controller.dart';
@ -16,11 +14,6 @@ import 'transform2d_effect.dart';
/// - [MoveEffect.to] will move the target in a straight line to the specified /// - [MoveEffect.to] will move the target in a straight line to the specified
/// coordinates; /// coordinates;
/// ///
/// - [MoveEffect.along] will move the target along the specified path, which
/// may contain curved segments, but must be simply-connected. The `path`
/// argument is taken as relative to the target's position at the start of
/// the effect.
///
/// This effect applies incremental changes to the component's position, and /// This effect applies incremental changes to the component's position, and
/// requires that any other effect or update logic applied to the same component /// requires that any other effect or update logic applied to the same component
/// also used incremental updates. /// also used incremental updates.
@ -32,9 +25,6 @@ class MoveEffect extends Transform2DEffect {
factory MoveEffect.to(Vector2 destination, EffectController controller) => factory MoveEffect.to(Vector2 destination, EffectController controller) =>
_MoveToEffect(destination, controller); _MoveToEffect(destination, controller);
factory MoveEffect.along(Path path, EffectController controller) =>
_MoveAlongPathEffect(path, controller);
Vector2 _offset; Vector2 _offset;
@override @override
@ -58,41 +48,3 @@ class _MoveToEffect extends MoveEffect {
_offset = _destination - target.position; _offset = _destination - target.position;
} }
} }
/// Implementation class for [MoveEffect.along].
class _MoveAlongPathEffect extends MoveEffect {
_MoveAlongPathEffect(Path path, EffectController controller)
: _lastPosition = Vector2.zero(),
super.by(Vector2.zero(), controller) {
final metrics = path.computeMetrics().toList();
if (metrics.length != 1) {
throw ArgumentError(
'Only single-contour paths are allowed in MoveEffect.along',
);
}
_pathMetric = metrics[0];
_pathLength = _pathMetric.length;
assert(_pathLength > 0);
}
late final PathMetric _pathMetric;
late final double _pathLength;
late Vector2 _lastPosition;
@override
void apply(double progress) {
final distance = progress * _pathLength;
final tangent = _pathMetric.getTangentForOffset(distance)!;
final offset = tangent.position;
target.position.x += offset.dx - _lastPosition.x;
target.position.y += offset.dy - _lastPosition.y;
_lastPosition.setValues(offset.dx, offset.dy);
super.apply(progress);
}
@override
void reset() {
super.reset();
_lastPosition = Vector2.zero();
}
}

View File

@ -0,0 +1,127 @@
import 'dart:math';
import 'dart:ui';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flame/game.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('MoveAlongPathEffect', () {
test('relative path', () {
const tau = Transform2D.tau;
const x0 = 32.5;
const y0 = 14.88;
final game = FlameGame();
game.onGameResize(Vector2(100, 100));
final object = PositionComponent()..position = Vector2(x0, y0);
game.add(object);
game.update(0);
object.add(
MoveAlongPathEffect(
Path()
..addOval(Rect.fromCircle(center: const Offset(6, 10), radius: 50)),
LinearEffectController(1),
),
);
game.update(0);
for (var i = 0; i < 100; i++) {
final a = tau * i / 100;
// Apparently, in Flutter circle paths are not truly circles, but only
// appear circle-ish to an unsuspecting observer. Which is why the
// precision in `closeTo()` is so low: only 0.1 pixels.
expect(object.position.x, closeTo(x0 + 6 + 50 * cos(a), 0.1));
expect(object.position.y, closeTo(y0 + 10 + 50 * sin(a), 0.1));
game.update(0.01);
}
});
test('absolute path', () {
final game = FlameGame()..onGameResize(Vector2(100, 100));
final component = PositionComponent()..position = Vector2(17, -5);
game.add(component);
game.update(0);
component.add(
MoveAlongPathEffect(
Path()
..moveTo(1000, 300)
..lineTo(1200, 500),
EffectController(duration: 1),
absolute: true,
),
);
game.update(0);
for (var i = 0; i < 10; i++) {
expect(component.position.x, closeTo(1000 + 200 * (i / 10), 1e-10));
expect(component.position.y, closeTo(300 + 200 * (i / 10), 1e-10));
game.update(0.1);
}
});
test('absolute oriented path', () {
final game = FlameGame()..onGameResize(Vector2(100, 100));
final component = PositionComponent(
position: Vector2(17, -5),
angle: -30.5,
);
game.add(component);
game.update(0);
component.add(
MoveAlongPathEffect(
Path() // pythagorean triangle, perimeter=600
..moveTo(200, 200)
..lineTo(290, 80)
..lineTo(450, 200)
..lineTo(200, 200),
EffectController(duration: 6),
absolute: true,
oriented: true,
),
);
game.update(0);
for (var i = 0; i < 60; i++) {
if (i <= 15) {
expect(component.position.x, closeTo(200 + 6 * i, 1e-10));
expect(component.position.y, closeTo(200 - 8 * i, 1e-10));
expect(component.angle, closeTo(-asin(0.8), 1e-7));
} else if (i <= 35) {
expect(component.position.x, closeTo(290 + 8 * (i - 15), 1e-10));
expect(component.position.y, closeTo(80 + 6 * (i - 15), 1e-10));
expect(component.angle, closeTo(asin(0.6), 1e-7));
} else {
expect(component.position.x, closeTo(450 - 10 * (i - 35), 1e-10));
expect(component.position.y, closeTo(200, 1e-10));
expect(component.angle, closeTo(pi, 1e-7));
}
game.update(0.1);
}
});
test('errors', () {
final controller = LinearEffectController(0);
expect(
() => MoveAlongPathEffect(Path(), controller),
throwsArgumentError,
);
final path2 = Path()
..moveTo(10, 10)
..lineTo(10, 10);
expect(
() => MoveAlongPathEffect(path2, controller),
throwsArgumentError,
);
final path3 = Path()
..addOval(const Rect.fromLTWH(0, 0, 1, 1))
..addOval(const Rect.fromLTWH(2, 2, 1, 1));
expect(
() => MoveAlongPathEffect(path3, controller),
throwsArgumentError,
);
});
});
}

View File

@ -1,6 +1,3 @@
import 'dart:math';
import 'dart:ui';
import 'package:flame/components.dart'; import 'package:flame/components.dart';
import 'package:flame/game.dart'; import 'package:flame/game.dart';
import 'package:flame/src/effects/controllers/linear_effect_controller.dart'; import 'package:flame/src/effects/controllers/linear_effect_controller.dart';
@ -44,56 +41,5 @@ void main() {
expect(object.position.x, closeTo(5, 1e-15)); expect(object.position.x, closeTo(5, 1e-15));
expect(object.position.y, closeTo(-1, 1e-15)); expect(object.position.y, closeTo(-1, 1e-15));
}); });
test('#along', () {
const tau = Transform2D.tau;
final game = FlameGame();
game.onGameResize(Vector2(100, 100));
final object = PositionComponent()..position = Vector2(3, 4);
game.add(object);
game.update(0);
object.add(
MoveEffect.along(
Path()
..addOval(Rect.fromCircle(center: const Offset(6, 10), radius: 50)),
LinearEffectController(1),
),
);
game.update(0);
for (var i = 0; i < 100; i++) {
final a = tau * i / 100;
// Apparently, in Flutter circle paths are not truly circles, but only
// appear circle-ish to an unsuspecting observer. Which is why the
// precision in `closeTo()` is so low: only 0.1 pixels.
expect(object.position.x, closeTo(3 + 6 + 50 * cos(a), 0.1));
expect(object.position.y, closeTo(4 + 10 + 50 * sin(a), 0.1));
game.update(0.01);
}
});
test('#along wrong arguments', () {
final controller = LinearEffectController(0);
expect(
() => MoveEffect.along(Path(), controller),
throwsArgumentError,
);
final path2 = Path()
..moveTo(10, 10)
..lineTo(10, 10);
expect(
() => MoveEffect.along(path2, controller),
throwsArgumentError,
);
final path3 = Path()
..addOval(const Rect.fromLTWH(0, 0, 1, 1))
..addOval(const Rect.fromLTWH(2, 2, 1, 1));
expect(
() => MoveEffect.along(path3, controller),
throwsArgumentError,
);
});
}); });
} }